@@ -1043,64 +1043,59 @@ export default {
// All radar-referenced blips (unfiltered), includes categoryTitle
const allBlips = computed(() => {
if (!project.value) return []
- const refs = Array.isArray(project.value.radarRefs) ? project.value.radarRefs : []
- if (!refs.length) return []
+ const entries = Array.isArray(project.value.radar) ? project.value.radar : []
+ if (!entries.length) return []
const lookup = entryLookup.value
const result = []
-
- for (const ref of refs) {
- const norm = String(ref.option || '').trim().toLowerCase()
- const entryData = lookup.get(ref.entryId)
+
+ for (const entry of entries) {
+ const norm = String(entry.option || '').trim().toLowerCase()
+ const entryData = lookup.get(entry.entryId)
const candidates = entryData?.candidates || []
-
- // Prefer the questionnaire that was active when the blip was added
- const preferredQId = String(ref.questionnaireId || '').trim()
+
+ // Find matching answer for type and questionnaire info
let match = null
-
- if (preferredQId) {
- for (const c of candidates) {
- if (c.tech.toLowerCase() === norm && c.questionnaireId === preferredQId) {
- match = c
- break
- }
- }
- }
-
- if (!match) {
- for (const c of candidates) {
- if (c.tech.toLowerCase() === norm) {
- match = c
- break
- }
+ for (const c of candidates) {
+ if (c.tech.toLowerCase() === norm) {
+ match = c
+ break
}
}
-
+
const answer = match?.answer
- const override = store.getRadarOverride(props.projectId, ref.entryId, ref.option)
- const effectiveStatus = (override?.status || '').trim() || String(answer?.status || '').trim()
- const effectiveCategory = (override?.categoryOverride || '').trim() || entryData?.categoryTitle || ''
-
+ const questionnaireStatus = String(answer?.status || '').trim()
+ const questionnaireCategory = entryData?.categoryTitle || ''
+ const radarStatus = (entry.status || '').trim()
+ const radarCategory = (entry.category || '').trim()
+
+ const effectiveStatus = radarStatus || questionnaireStatus
+ const effectiveCategory = radarCategory || questionnaireCategory
+
+ // Flags for "has user override" indicator (pencil icon)
+ const overrideStatus = radarStatus && radarStatus !== questionnaireStatus ? radarStatus : ''
+ const overrideCategoryTitle = radarCategory && radarCategory !== questionnaireCategory ? radarCategory : ''
+
result.push({
- key: `${ref.entryId}||${ref.option}`,
- entryId: ref.entryId,
- option: String(ref.option || '').trim(),
- name: String(ref.option || '').trim(),
+ key: `${entry.entryId}||${entry.option}`,
+ entryId: entry.entryId,
+ option: String(entry.option || '').trim(),
+ name: String(entry.option || '').trim(),
status: effectiveStatus,
answerType: String(answer?.answerType || '').trim(),
comment: String(answer?.comments || '').trim(),
- radarComment: String(override?.comment || '').trim(),
- shortComment: String(override?.shortComment || '').trim(),
- overrideStatus: String(override?.status || '').trim(),
- overrideCategoryTitle: String(override?.categoryOverride || '').trim(),
- naturalCategoryTitle: entryData?.categoryTitle || '',
+ radarComment: String(entry.description || '').trim(),
+ shortComment: String(entry.shortComment || '').trim(),
+ overrideStatus,
+ overrideCategoryTitle,
+ naturalCategoryTitle: questionnaireCategory,
questionnaireName: match?.questionnaireName || '',
categoryTitle: effectiveCategory,
entryTitle: entryData?.entryTitle || '',
ring: statusToRing(effectiveStatus)
})
}
-
+
return result
})
@@ -1467,14 +1462,11 @@ export default {
function saveEdit () {
if (!blipToEdit.value) return
- // Don't store a category override when it matches the natural category
- const catOverride = (editForm.value.categoryOverride || '').trim()
- const naturalCat = (blipToEdit.value.naturalCategoryTitle || '').trim()
store.setRadarOverride(props.projectId, blipToEdit.value.entryId, blipToEdit.value.option, {
status: editForm.value.status,
shortComment: editForm.value.shortComment,
comment: editForm.value.comment,
- categoryOverride: catOverride === naturalCat ? '' : catOverride
+ categoryOverride: (editForm.value.categoryOverride || '').trim() || blipToEdit.value.naturalCategoryTitle || ''
})
editDialog.value = false
blipToEdit.value = null
diff --git a/Client/src/stores/workspaceStore.js b/Client/src/stores/workspaceStore.js
index d8e1b81..4cc46f1 100644
--- a/Client/src/stores/workspaceStore.js
+++ b/Client/src/stores/workspaceStore.js
@@ -101,9 +101,35 @@ export const useWorkspaceStore = defineStore('workspace', () => {
return [...projectTabs, ...questionnaireTabs]
})
+ function migrateProjectRadar(project) {
+ // Already migrated to new format
+ if (Array.isArray(project.radar)) return
+ // Migrate from legacy radarRefs + radarOverrides to unified radar array
+ const refs = Array.isArray(project.radarRefs) ? project.radarRefs : []
+ const overrides = Array.isArray(project.radarOverrides) ? project.radarOverrides : []
+ project.radar = refs.map((ref) => {
+ const norm = String(ref.option || '').trim().toLowerCase()
+ const override = overrides.find(
+ (o) => o.entryId === ref.entryId && String(o.option || '').toLowerCase() === norm
+ )
+ return {
+ entryId: ref.entryId,
+ option: String(ref.option || '').trim(),
+ category: String(override?.categoryOverride || '').trim(),
+ status: String(override?.status || '').trim(),
+ shortComment: String(override?.shortComment || '').trim(),
+ description: String(override?.comment || '').trim()
+ }
+ })
+ delete project.radarRefs
+ delete project.radarOverrides
+ }
+
function applyStoredData(data) {
if (data.version === STORAGE_VERSION && data.workspace) {
workspace.value = data.workspace
+ // Migrate any projects still using the legacy two-array format
+ ;(workspace.value.projects || []).forEach(migrateProjectRadar)
// Restore open tabs and active state, filtering out IDs that no longer exist
const existingIds = new Set(data.workspace.questionnaires?.map((q) => q.id) || [])
const restoredOpen = (data.openQuestionnaireIds || []).filter((id) => existingIds.has(id))
@@ -177,7 +203,25 @@ export const useWorkspaceStore = defineStore('workspace', () => {
function loadFromData(data) {
if (!data) return false
const ok = applyStoredData(data)
- if (ok) workspaceDirNeeded.value = false
+ if (ok) {
+ workspaceDirNeeded.value = false
+ // If no tabs were restored, auto-open the first available item so the user
+ // gets visual feedback that the workspace loaded successfully.
+ if (openProjectSummaryIds.value.length === 0 && openQuestionnaireIds.value.length === 0) {
+ const firstProject = workspace.value.projects?.[0]
+ if (firstProject) {
+ openProjectSummaryIds.value = [firstProject.id]
+ activeWorkspaceTabId.value = toProjectTabId(firstProject.id)
+ } else {
+ const firstQuestionnaire = workspace.value.questionnaires?.[0]
+ if (firstQuestionnaire) {
+ openQuestionnaireIds.value = [firstQuestionnaire.id]
+ activeQuestionnaireId.value = firstQuestionnaire.id
+ activeWorkspaceTabId.value = firstQuestionnaire.id
+ }
+ }
+ }
+ }
return ok
}
@@ -414,8 +458,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
workspace.value.questionnaires.push(copy)
newProject.questionnaireIds = [...(newProject.questionnaireIds || []), copy.id]
})
- if (Array.isArray(source.radarRefs)) newProject.radarRefs = JSON.parse(JSON.stringify(source.radarRefs))
- if (Array.isArray(source.radarOverrides)) newProject.radarOverrides = JSON.parse(JSON.stringify(source.radarOverrides))
+ if (Array.isArray(source.radar)) newProject.radar = JSON.parse(JSON.stringify(source.radar))
if (Array.isArray(source.radarCategoryOrder)) newProject.radarCategoryOrder = [...source.radarCategoryOrder]
return newProjectId
}
@@ -465,8 +508,13 @@ export const useWorkspaceStore = defineStore('workspace', () => {
? [...project.questionnaireIds, created.id]
: [created.id]
})
- if (Array.isArray(radarData.radarRefs)) project.radarRefs = radarData.radarRefs
- if (Array.isArray(radarData.radarOverrides)) project.radarOverrides = radarData.radarOverrides
+ if (Array.isArray(radarData.radar)) {
+ project.radar = radarData.radar
+ } else if (Array.isArray(radarData.radarRefs) || Array.isArray(radarData.radarOverrides)) {
+ // Migrate legacy format from imported file
+ Object.assign(project, { radarRefs: radarData.radarRefs, radarOverrides: radarData.radarOverrides })
+ migrateProjectRadar(project)
+ }
if (Array.isArray(radarData.radarCategoryOrder)) project.radarCategoryOrder = radarData.radarCategoryOrder
}
@@ -593,8 +641,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
project: {
id: project.id,
name: project.name,
- radarRefs: Array.isArray(project.radarRefs) ? project.radarRefs : [],
- radarOverrides: Array.isArray(project.radarOverrides) ? project.radarOverrides : [],
+ radar: Array.isArray(project.radar) ? project.radar : [],
radarCategoryOrder: Array.isArray(project.radarCategoryOrder) ? project.radarCategoryOrder : []
},
questionnaires
@@ -657,53 +704,89 @@ export const useWorkspaceStore = defineStore('workspace', () => {
function toggleProjectRadarRef(projectId, entryId, option, questionnaireId = '') {
const project = workspace.value.projects.find((p) => p.id === projectId)
if (!project) return
- if (!Array.isArray(project.radarRefs)) project.radarRefs = []
+ if (!Array.isArray(project.radar)) project.radar = []
const norm = String(option || '').trim().toLowerCase()
- const idx = project.radarRefs.findIndex(
+ const idx = project.radar.findIndex(
(r) => r.entryId === entryId && String(r.option || '').toLowerCase() === norm
)
if (idx !== -1) {
- project.radarRefs.splice(idx, 1)
+ project.radar.splice(idx, 1)
} else {
- project.radarRefs.push({
+ // Populate category, status and shortComment from the questionnaire entry
+ let categoryTitle = ''
+ let answerStatus = ''
+ let answerComments = ''
+ const projectQuestionnaires = workspace.value.questionnaires.filter(
+ (q) => (project.questionnaireIds || []).includes(q.id)
+ )
+ const preferred = projectQuestionnaires.find((q) => q.id === questionnaireId)
+ const toSearch = preferred
+ ? [preferred, ...projectQuestionnaires.filter((q) => q.id !== questionnaireId)]
+ : projectQuestionnaires
+ for (const q of toSearch) {
+ for (const cat of (q.categories || [])) {
+ if (cat.isMetadata) continue
+ const entry = (cat.entries || []).find((e) => e.id === entryId)
+ if (entry) {
+ categoryTitle = String(cat.title || '').trim()
+ const answer = (entry.answers || []).find(
+ (a) => String(a.technology || '').trim().toLowerCase() === norm
+ )
+ if (answer) {
+ answerStatus = String(answer.status || '').trim()
+ answerComments = String(answer.comments || '').trim()
+ }
+ break
+ }
+ }
+ if (categoryTitle) break
+ }
+ project.radar.push({
entryId,
option: String(option || '').trim(),
- questionnaireId: String(questionnaireId || '').trim()
+ category: categoryTitle,
+ status: answerStatus,
+ shortComment: answerComments,
+ description: ''
})
}
}
function isProjectRadarRef(projectId, entryId, option) {
const project = workspace.value.projects.find((p) => p.id === projectId)
- if (!project || !Array.isArray(project.radarRefs)) return false
+ if (!project || !Array.isArray(project.radar)) return false
const norm = String(option || '').trim().toLowerCase()
- return project.radarRefs.some(
+ return project.radar.some(
(r) => r.entryId === entryId && String(r.option || '').toLowerCase() === norm
)
}
function getRadarOverride(projectId, entryId, option) {
const project = workspace.value.projects.find((p) => p.id === projectId)
- if (!project || !Array.isArray(project.radarOverrides)) return null
+ if (!project || !Array.isArray(project.radar)) return null
const norm = String(option || '').trim().toLowerCase()
- return project.radarOverrides.find(
- (o) => o.entryId === entryId && String(o.option || '').toLowerCase() === norm
+ return project.radar.find(
+ (r) => r.entryId === entryId && String(r.option || '').toLowerCase() === norm
) || null
}
function setRadarOverride(projectId, entryId, option, { status, comment, shortComment = '', categoryOverride = '' }) {
const project = workspace.value.projects.find((p) => p.id === projectId)
if (!project) return
- if (!Array.isArray(project.radarOverrides)) project.radarOverrides = []
+ if (!Array.isArray(project.radar)) project.radar = []
const norm = String(option || '').trim().toLowerCase()
- const idx = project.radarOverrides.findIndex(
- (o) => o.entryId === entryId && String(o.option || '').toLowerCase() === norm
+ const idx = project.radar.findIndex(
+ (r) => r.entryId === entryId && String(r.option || '').toLowerCase() === norm
)
- const record = { entryId, option: String(option || '').trim(), status: String(status || ''), shortComment: String(shortComment || ''), comment: String(comment || ''), categoryOverride: String(categoryOverride || '') }
if (idx !== -1) {
- project.radarOverrides.splice(idx, 1, record)
- } else {
- project.radarOverrides.push(record)
+ const existing = project.radar[idx]
+ project.radar.splice(idx, 1, {
+ ...existing,
+ status: String(status || ''),
+ shortComment: String(shortComment || ''),
+ description: String(comment || ''),
+ category: String(categoryOverride || existing.category || '')
+ })
}
}
diff --git a/MCP/McpServer/Data/ProjectRepository.cs b/MCP/McpServer/Data/ProjectRepository.cs
index a7a1687..fce2b3a 100644
--- a/MCP/McpServer/Data/ProjectRepository.cs
+++ b/MCP/McpServer/Data/ProjectRepository.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
using McpServer.Models;
namespace McpServer.Data;
@@ -14,23 +15,13 @@ public sealed class ProjectRepository
PropertyNameCaseInsensitive = true
};
- // Candidate paths searched when loading the built-in example (relative to content root).
- private static readonly string[] ExampleRelativePaths =
- [
- "Data/example_export.json",
- "../../Client/tests/data/example_export.json",
- "../../../Client/tests/data/example_export.json",
- ];
-
- private readonly IWebHostEnvironment _env;
private readonly ILogger _logger;
private readonly SemaphoreSlim _sem = new(1, 1);
private WorkspaceExport? _workspace;
- public ProjectRepository(IWebHostEnvironment env, ILogger logger)
+ public ProjectRepository(ILogger logger)
{
- _env = env;
_logger = logger;
}
@@ -52,8 +43,8 @@ public ProjectRepository(IWebHostEnvironment env, ILogger log
await _sem.WaitAsync();
try
{
- await using var stream = File.OpenRead(fullPath);
- var ws = await JsonSerializer.DeserializeAsync(stream, s_opts);
+ var json = await File.ReadAllTextAsync(fullPath);
+ var ws = ParseWorkspaceExport(json);
if (ws is null)
return (false, "Deserialization returned null.");
@@ -82,7 +73,7 @@ public ProjectRepository(IWebHostEnvironment env, ILogger log
await _sem.WaitAsync();
try
{
- var ws = JsonSerializer.Deserialize(json, s_opts);
+ var ws = ParseWorkspaceExport(json);
if (ws is null)
return (false, "Deserialization returned null.");
@@ -102,21 +93,6 @@ public ProjectRepository(IWebHostEnvironment env, ILogger log
}
}
- /// Attempts to load the built-in example workspace from well-known locations.
- public async Task<(bool Success, string Message)> LoadExampleAsync()
- {
- foreach (var rel in ExampleRelativePaths)
- {
- var full = Path.GetFullPath(Path.Combine(_env.ContentRootPath, rel));
- if (File.Exists(full))
- return await LoadFromFileAsync(full);
- }
-
- var tried = string.Join(", ", ExampleRelativePaths);
- _logger.LogWarning("Built-in example workspace not found. Tried: {Paths}", tried);
- return (false, $"Example file not found. Tried: {tried}");
- }
-
// ββ Query βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private static IEnumerable FilterQuestionnaires(
@@ -257,167 +233,176 @@ private static IEnumerable FilterQuestionnaires(
return records.AsReadOnly();
}
- /// Returns the tech radar (overrides + category order) from the loaded project.
+ /// Returns the tech radar from the loaded project, migrating from legacy format if needed.
+ /// Radar entries with no stored status are enriched from the matching questionnaire answer.
+ ///
public TechRadarData? GetTechRadar()
{
if (_workspace?.Project is null) return null;
var p = _workspace.Project;
- return new TechRadarData(
- p.RadarOverrides.AsReadOnly(),
- p.RadarRefs.AsReadOnly(),
- p.RadarCategoryOrder.AsReadOnly());
- }
- // ββ Quality assessment ββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- ///
- /// Evaluates the response quality of a single questionnaire.
- /// Returns a consistency score (0β1), a completeness percentage (0β100),
- /// and a list of human-readable warnings describing detected issues.
- /// Returns when no workspace is loaded or the
- /// questionnaire cannot be found.
- ///
- public EvaluateResponsesResult? EvaluateResponses(string questionnaireId)
- {
- if (_workspace is null) return null;
-
- var questionnaire = _workspace.Questionnaires.FirstOrDefault(q =>
- q.Id.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase) ||
- q.Name.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase));
+ List entries;
- if (questionnaire is null) return null;
+ if (p.Radar.Count > 0)
+ {
+ // New unified format: use radar array directly
+ entries = p.Radar.ToList();
+ }
+ else
+ {
+ // Legacy format: radarRefs defines the set; radarOverrides carries optional edits.
+ // The client migration (migrateProjectRadar) starts from radarRefs and applies
+ // overrides on top β we must do the same here so refs-without-overrides are included.
+ entries = p.RadarRefs.Select(r =>
+ {
+ var norm = r.Option.Trim().ToLowerInvariant();
+ var override_ = p.RadarOverrides.FirstOrDefault(o =>
+ o.EntryId.Equals(r.EntryId, StringComparison.OrdinalIgnoreCase) &&
+ o.Option.Trim().ToLowerInvariant() == norm);
- var warnings = new List();
+ return new RadarEntry
+ {
+ EntryId = r.EntryId,
+ Option = r.Option.Trim(),
+ Category = string.IsNullOrWhiteSpace(override_?.CategoryOverride)
+ ? string.Empty
+ : override_!.CategoryOverride,
+ Status = override_?.Status ?? string.Empty,
+ ShortComment = override_?.ShortComment ?? string.Empty,
+ Description = override_?.Comment ?? string.Empty
+ };
+ }).ToList();
+ }
- // ββ Completeness ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Enrich entries that have no stored status or category by looking up
+ // the matching questionnaire answer β identical to the client's
+ // effectiveStatus = radarStatus || questionnaireStatus logic.
+ if (_workspace.Questionnaires.Count > 0 && entries.Any(e =>
+ string.IsNullOrWhiteSpace(e.Status) || string.IsNullOrWhiteSpace(e.Category)))
+ {
+ var answerLookup = BuildAnswerLookup(_workspace.Questionnaires);
+ entries = entries.Select(e =>
+ {
+ if (!string.IsNullOrWhiteSpace(e.Status) && !string.IsNullOrWhiteSpace(e.Category))
+ return e;
- var metaCat = questionnaire.Categories.FirstOrDefault(c => c.IsMetadata == true);
- var meta = metaCat?.Metadata;
+ var key = $"{e.EntryId}|{e.Option.Trim().ToLowerInvariant()}";
+ if (!answerLookup.TryGetValue(key, out var found))
+ return e;
- // Mandatory metadata fields
- var mandatoryFields = new Dictionary
- {
- ["productName"] = meta?.ProductName,
- ["company"] = meta?.Company,
- ["department"] = meta?.Department,
- ["contactPerson"] = meta?.ContactPerson,
- ["executionType"] = meta?.ExecutionType,
- ["architecturalRole"] = meta?.ArchitecturalRole,
- };
-
- int filledMetadata = 0;
- foreach (var (field, value) in mandatoryFields)
- {
- if (string.IsNullOrWhiteSpace(value))
- warnings.Add($"Missing mandatory metadata field: '{field}'.");
- else
- filledMetadata++;
+ return e with
+ {
+ Status = string.IsNullOrWhiteSpace(e.Status) ? found.Status : e.Status,
+ Category = string.IsNullOrWhiteSpace(e.Category) ? found.Category : e.Category
+ };
+ }).ToList();
}
- // Non-metadata entries
- var nonMetaCategories = questionnaire.Categories
- .Where(c => c.IsMetadata != true)
- .ToList();
+ return new TechRadarData(entries.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly());
+ }
- var allEntries = nonMetaCategories
- .SelectMany(c => c.Entries ?? [])
- .ToList();
+ // ββ Private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ ///
+ /// Detects and normalises two workspace file formats:
+ ///
+ /// Standard project export: { project, questionnaires }
+ /// Full Electron autosave: { version, timestamp, workspace: { projects, questionnaires } }
+ ///
+ /// For the full format the first project is used and only its questionnaires are kept.
+ ///
+ private static WorkspaceExport? ParseWorkspaceExport(string json)
+ {
+ // Quick attempt at the standard project-export format
+ var standard = JsonSerializer.Deserialize(json, s_opts);
+ if (standard?.Project is not null)
+ return standard;
- int entriesWithAnswers = 0;
- foreach (var cat in nonMetaCategories)
+ // Try the full Electron client format:
+ // { "version": 2, "workspace": { "projects": [...], "questionnaires": [...] } }
+ try
{
- foreach (var entry in cat.Entries ?? [])
+ var root = JsonNode.Parse(json);
+ var workspaceNode = root?["workspace"];
+ if (workspaceNode is null) return standard;
+
+ var projectsNode = workspaceNode["projects"]?.AsArray();
+ if (projectsNode is null || projectsNode.Count == 0) return standard;
+
+ // Use the first project in the file
+ var projectNode = projectsNode[0]!;
+
+ // Collect the questionnaire IDs that belong to this project
+ var questionnaireIds = (projectNode["questionnaireIds"]?.AsArray() ?? [])
+ .Select(n => n?.GetValue() ?? string.Empty)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Filter the global questionnaire list to only this project's questionnaires
+ var allQuestionnaires = workspaceNode["questionnaires"]?.AsArray() ?? [];
+ var filteredQuestionnaires = new JsonArray();
+ foreach (var q in allQuestionnaires)
{
- if (entry.Answers is { Count: > 0 })
- entriesWithAnswers++;
- else
- warnings.Add(
- $"Entry '{entry.Aspect}' ('{entry.Id}') in category '{cat.Title}' has no answers.");
+ var qId = q?["id"]?.GetValue() ?? string.Empty;
+ if (questionnaireIds.Count == 0 || questionnaireIds.Contains(qId))
+ filteredQuestionnaires.Add(q?.DeepClone());
}
- }
- int totalCompletable = mandatoryFields.Count + allEntries.Count;
- float completeness = totalCompletable > 0
- ? (float)(filledMetadata + entriesWithAnswers) / totalCompletable * 100f
- : 100f;
+ var normalised = new JsonObject
+ {
+ ["project"] = projectNode.DeepClone(),
+ ["questionnaires"] = filteredQuestionnaires
+ };
- // ββ Consistency βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ return JsonSerializer.Deserialize(normalised.ToJsonString(), s_opts);
+ }
+ catch
+ {
+ return standard;
+ }
+ }
- // Collect all statuses per technology name across the questionnaire.
- var techStatusMap = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Builds a lookup table from questionnaire answers:
+ /// key = "{entryId}|{optionLowercase}",
+ /// value = (Status, CategoryTitle).
+ /// Used to enrich radar entries whose stored status / category is empty.
+ ///
+ private static Dictionary BuildAnswerLookup(
+ IEnumerable questionnaires)
+ {
+ var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var cat in nonMetaCategories)
+ foreach (var q in questionnaires)
{
- foreach (var entry in cat.Entries ?? [])
+ foreach (var cat in q.Categories)
{
- if (entry.Answers is null) continue;
-
- // Within-entry: flag duplicate technology entries.
- var duplicates = entry.Answers
- .GroupBy(a => a.Technology, StringComparer.OrdinalIgnoreCase)
- .Where(g => g.Count() > 1);
-
- foreach (var group in duplicates)
- {
- var statuses = group
- .Select(a => a.Status)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToList();
-
- warnings.Add(statuses.Count > 1
- ? $"Technology '{group.Key}' in entry '{entry.Aspect}' has conflicting statuses: {string.Join(", ", statuses)}."
- : $"Technology '{group.Key}' is listed {group.Count()} times in entry '{entry.Aspect}'.");
- }
-
- // Collect statuses for cross-entry consistency check.
- foreach (var answer in entry.Answers)
+ if (cat.IsMetadata == true) continue;
+ foreach (var entry in cat.Entries ?? [])
{
- if (!techStatusMap.TryGetValue(answer.Technology, out var set))
+ foreach (var answer in entry.Answers ?? [])
{
- set = new HashSet(StringComparer.OrdinalIgnoreCase);
- techStatusMap[answer.Technology] = set;
+ if (string.IsNullOrWhiteSpace(answer.Technology)) continue;
+ var key = $"{entry.Id}|{answer.Technology.Trim().ToLowerInvariant()}";
+ if (!lookup.ContainsKey(key))
+ lookup[key] = (answer.Status ?? string.Empty, cat.Title ?? string.Empty);
}
- if (!string.IsNullOrWhiteSpace(answer.Status))
- set.Add(answer.Status);
- }
-
- // Flag Hold / Retire answers without an explanatory comment.
- foreach (var answer in entry.Answers)
- {
- bool isCritical =
- answer.Status?.Equals("Hold", StringComparison.OrdinalIgnoreCase) == true ||
- answer.Status?.Equals("Retire", StringComparison.OrdinalIgnoreCase) == true;
-
- if (isCritical && string.IsNullOrWhiteSpace(answer.Comments))
- warnings.Add(
- $"Technology '{answer.Technology}' has status '{answer.Status}' in entry '{entry.Aspect}' without an explanatory comment.");
}
}
}
- // Cross-entry consistency: same technology, different statuses.
- int consistent = 0, inconsistent = 0;
- foreach (var (tech, statuses) in techStatusMap)
- {
- if (statuses.Count > 1)
- {
- inconsistent++;
- warnings.Add(
- $"Technology '{tech}' appears with inconsistent statuses across the questionnaire: {string.Join(", ", statuses)}.");
- }
- else
- {
- consistent++;
- }
- }
+ return lookup;
+ }
- int total = consistent + inconsistent;
- float consistencyScore = total > 0 ? (float)consistent / total : 1f;
+ // ββ Questionnaire lookup ββββββββββββββββββββββββββββββββββββββββββββββββββ
- return new EvaluateResponsesResult(
- (float)Math.Round(consistencyScore, 2, MidpointRounding.AwayFromZero),
- (float)Math.Round(completeness, 2, MidpointRounding.AwayFromZero),
- warnings.AsReadOnly());
+ /// Finds a questionnaire by ID or name. Returns when not found or no workspace is loaded.
+ public Questionnaire? FindQuestionnaire(string questionnaireId)
+ {
+ if (_workspace is null) return null;
+ return _workspace.Questionnaires.FirstOrDefault(q =>
+ q.Id.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase) ||
+ q.Name.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase));
}
// ββ Summary helper (used by the UI API) βββββββββββββββββββββββββββββββββββ
diff --git a/MCP/McpServer/Logic/QuestionnaireEvaluator.cs b/MCP/McpServer/Logic/QuestionnaireEvaluator.cs
new file mode 100644
index 0000000..60a68e6
--- /dev/null
+++ b/MCP/McpServer/Logic/QuestionnaireEvaluator.cs
@@ -0,0 +1,150 @@
+using McpServer.Models;
+
+namespace McpServer.Logic;
+
+///
+/// Evaluates the response quality of a single questionnaire.
+/// Contains all evaluation logic that was previously embedded in .
+///
+public sealed class QuestionnaireEvaluator
+{
+ ///
+ /// Evaluates consistency and completeness of the given questionnaire.
+ /// Returns a consistency score (0β1), a completeness percentage (0β100),
+ /// and a list of human-readable warnings describing detected issues.
+ ///
+ public EvaluateResponsesResult Evaluate(Questionnaire questionnaire)
+ {
+ var warnings = new List();
+
+ // ββ Completeness ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ var metaCat = questionnaire.Categories.FirstOrDefault(c => c.IsMetadata == true);
+ var meta = metaCat?.Metadata;
+
+ // Mandatory metadata fields
+ var mandatoryFields = new Dictionary
+ {
+ ["productName"] = meta?.ProductName,
+ ["company"] = meta?.Company,
+ ["department"] = meta?.Department,
+ ["contactPerson"] = meta?.ContactPerson,
+ ["executionType"] = meta?.ExecutionType,
+ ["architecturalRole"] = meta?.ArchitecturalRole,
+ };
+
+ int filledMetadata = 0;
+ foreach (var (field, value) in mandatoryFields)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ warnings.Add($"Missing mandatory metadata field: '{field}'.");
+ else
+ filledMetadata++;
+ }
+
+ // Non-metadata entries
+ var nonMetaCategories = questionnaire.Categories
+ .Where(c => c.IsMetadata != true)
+ .ToList();
+
+ var allEntries = nonMetaCategories
+ .SelectMany(c => c.Entries ?? [])
+ .ToList();
+
+ int entriesWithAnswers = 0;
+ foreach (var cat in nonMetaCategories)
+ {
+ foreach (var entry in cat.Entries ?? [])
+ {
+ if (entry.Answers is { Count: > 0 })
+ entriesWithAnswers++;
+ else
+ warnings.Add(
+ $"Entry '{entry.Aspect}' ('{entry.Id}') in category '{cat.Title}' has no answers.");
+ }
+ }
+
+ int totalCompletable = mandatoryFields.Count + allEntries.Count;
+ float completeness = totalCompletable > 0
+ ? (float)(filledMetadata + entriesWithAnswers) / totalCompletable * 100f
+ : 100f;
+
+ // ββ Consistency βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ // Collect all statuses per technology name across the questionnaire.
+ var techStatusMap = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var cat in nonMetaCategories)
+ {
+ foreach (var entry in cat.Entries ?? [])
+ {
+ if (entry.Answers is null) continue;
+
+ // Within-entry: flag duplicate technology entries.
+ var duplicates = entry.Answers
+ .GroupBy(a => a.Technology, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1);
+
+ foreach (var group in duplicates)
+ {
+ var statuses = group
+ .Select(a => a.Status)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ warnings.Add(statuses.Count > 1
+ ? $"Technology '{group.Key}' in entry '{entry.Aspect}' has conflicting statuses: {string.Join(", ", statuses)}."
+ : $"Technology '{group.Key}' is listed {group.Count()} times in entry '{entry.Aspect}'.");
+ }
+
+ // Collect statuses for cross-entry consistency check.
+ foreach (var answer in entry.Answers)
+ {
+ if (!techStatusMap.TryGetValue(answer.Technology, out var set))
+ {
+ set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ techStatusMap[answer.Technology] = set;
+ }
+ if (!string.IsNullOrWhiteSpace(answer.Status))
+ set.Add(answer.Status);
+ }
+
+ // Flag Hold / Retire answers without an explanatory comment.
+ foreach (var answer in entry.Answers)
+ {
+ bool isCritical =
+ answer.Status?.Equals("Hold", StringComparison.OrdinalIgnoreCase) == true ||
+ answer.Status?.Equals("Retire", StringComparison.OrdinalIgnoreCase) == true;
+
+ if (isCritical && string.IsNullOrWhiteSpace(answer.Comments))
+ warnings.Add(
+ $"Technology '{answer.Technology}' has status '{answer.Status}' in entry '{entry.Aspect}' without an explanatory comment.");
+ }
+ }
+ }
+
+ // Cross-entry consistency: same technology, different statuses.
+ int consistent = 0, inconsistent = 0;
+ foreach (var (tech, statuses) in techStatusMap)
+ {
+ if (statuses.Count > 1)
+ {
+ inconsistent++;
+ warnings.Add(
+ $"Technology '{tech}' appears with inconsistent statuses across the questionnaire: {string.Join(", ", statuses)}.");
+ }
+ else
+ {
+ consistent++;
+ }
+ }
+
+ int total = consistent + inconsistent;
+ float consistencyScore = total > 0 ? (float)consistent / total : 1f;
+
+ return new EvaluateResponsesResult(
+ (float)Math.Round(consistencyScore, 2, MidpointRounding.AwayFromZero),
+ (float)Math.Round(completeness, 2, MidpointRounding.AwayFromZero),
+ warnings.AsReadOnly());
+ }
+}
diff --git a/MCP/McpServer/Models/ProjectData.cs b/MCP/McpServer/Models/ProjectData.cs
index 6f09376..28496ed 100644
--- a/MCP/McpServer/Models/ProjectData.cs
+++ b/MCP/McpServer/Models/ProjectData.cs
@@ -10,6 +10,10 @@ public record ProjectData
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
+ [JsonPropertyName("radar")]
+ public List Radar { get; init; } = [];
+
+ // Legacy fields kept for backward-compatible reading of old workspace exports
[JsonPropertyName("radarRefs")]
public List RadarRefs { get; init; } = [];
diff --git a/MCP/McpServer/Models/RadarEntry.cs b/MCP/McpServer/Models/RadarEntry.cs
new file mode 100644
index 0000000..750981c
--- /dev/null
+++ b/MCP/McpServer/Models/RadarEntry.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace McpServer.Models;
+
+public record RadarEntry
+{
+ [JsonPropertyName("entryId")]
+ public string EntryId { get; init; } = string.Empty;
+
+ [JsonPropertyName("option")]
+ public string Option { get; init; } = string.Empty;
+
+ [JsonPropertyName("category")]
+ public string Category { get; init; } = string.Empty;
+
+ [JsonPropertyName("status")]
+ public string Status { get; init; } = string.Empty;
+
+ [JsonPropertyName("shortComment")]
+ public string ShortComment { get; init; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string Description { get; init; } = string.Empty;
+}
diff --git a/MCP/McpServer/Models/WorkspaceQueryModels.cs b/MCP/McpServer/Models/WorkspaceQueryModels.cs
index 46e44af..8902b56 100644
--- a/MCP/McpServer/Models/WorkspaceQueryModels.cs
+++ b/MCP/McpServer/Models/WorkspaceQueryModels.cs
@@ -39,8 +39,7 @@ public record AnswerRecord(
);
public record TechRadarData(
- IReadOnlyList Overrides,
- IReadOnlyList Refs,
+ IReadOnlyList Entries,
IReadOnlyList CategoryOrder
);
diff --git a/MCP/McpServer/Program.cs b/MCP/McpServer/Program.cs
index ef86093..def5503 100644
--- a/MCP/McpServer/Program.cs
+++ b/MCP/McpServer/Program.cs
@@ -1,5 +1,6 @@
using System.Net.WebSockets;
using McpServer.Data;
+using McpServer.Logic;
using McpServer.Models;
using McpServer.Services;
@@ -8,6 +9,7 @@
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
var app = builder.Build();
@@ -53,13 +55,6 @@
});
// ββ Workspace API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-app.MapPost("/api/workspace/load-example", async (ProjectRepository repo, LogBroadcaster log) =>
-{
- var (success, message) = await repo.LoadExampleAsync();
- await log.LogAsync(success ? "INFO" : "WARNING", $"Workspace load-example: {message}");
- return success ? Results.Ok(new { message }) : Results.UnprocessableEntity(new { message });
-});
-
app.MapPost("/api/workspace/load", async (ProjectRepository repo, LogBroadcaster log, HttpRequest req) =>
{
var body = await req.ReadFromJsonAsync();
diff --git a/MCP/McpServer/Services/JsonSchemas.cs b/MCP/McpServer/Services/JsonSchemas.cs
new file mode 100644
index 0000000..4625d4a
--- /dev/null
+++ b/MCP/McpServer/Services/JsonSchemas.cs
@@ -0,0 +1,324 @@
+namespace McpServer.Services;
+
+///
+/// JSON Schema (Draft-07) definitions for SolutionInventory data formats.
+/// Derived from categoriesService.js and the C# model classes.
+///
+internal static class JsonSchemas
+{
+ /// Schema for the full workspace export file (project + questionnaires).
+ public static string WorkspaceSchema => WorkspaceSchemaJson;
+
+ /// Schema for a single questionnaire document.
+ public static string QuestionnaireSchema => QuestionnaireSchemaJson;
+
+ private const string WorkspaceSchemaJson = """
+{
+ "$schema": "https://json-schema.org/draft-07/schema#",
+ "title": "SolutionInventory Workspace Export",
+ "description": "Complete workspace export format. Contains one project with tech-radar data and a list of technology-assessment questionnaires.",
+ "type": "object",
+ "properties": {
+ "project": {
+ "$ref": "#/$defs/ProjectData"
+ },
+ "questionnaires": {
+ "type": "array",
+ "description": "Technology-assessment questionnaires belonging to this project.",
+ "items": { "$ref": "#/$defs/Questionnaire" }
+ }
+ },
+ "$defs": {
+ "ProjectData": {
+ "type": "object",
+ "description": "Project definition including the tech-radar.",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "string", "description": "Unique project identifier." },
+ "name": { "type": "string", "description": "Project display name." },
+ "radar": {
+ "type": "array",
+ "description": "Tech-radar entries for this project.",
+ "items": { "$ref": "#/$defs/RadarEntry" }
+ },
+ "radarCategoryOrder": {
+ "type": "array",
+ "description": "Ordered list of category IDs controlling the radar display sequence.",
+ "items": { "$ref": "#/$defs/CategoryId" }
+ }
+ }
+ },
+ "RadarEntry": {
+ "type": "object",
+ "description": "A positioned technology entry on the tech radar.",
+ "required": ["entryId", "option", "category", "status"],
+ "properties": {
+ "entryId": { "type": "string", "description": "Unique identifier for this radar entry." },
+ "option": { "type": "string", "description": "Technology or practice name (e.g. 'Vue.js', 'Docker', 'Clean Arch')." },
+ "category": { "$ref": "#/$defs/CategoryId", "description": "Radar category this entry belongs to." },
+ "status": { "$ref": "#/$defs/Status" },
+ "shortComment": { "type": "string", "description": "Short rationale shown on the radar visualization." },
+ "description": { "type": "string", "description": "Full description and reasoning." }
+ }
+ },
+ "Questionnaire": {
+ "type": "object",
+ "description": "A technology-assessment questionnaire (one per assessed solution or component).",
+ "required": ["id", "name", "categories"],
+ "properties": {
+ "id": { "type": "string", "description": "Unique questionnaire identifier." },
+ "name": { "type": "string", "description": "Display name β usually the component or solution name." },
+ "categories": {
+ "type": "array",
+ "description": "Ordered list of category responses.",
+ "items": { "$ref": "#/$defs/QuestionnaireCategory" }
+ }
+ }
+ },
+ "QuestionnaireCategory": {
+ "type": "object",
+ "description": "Responses for a single questionnaire category.",
+ "required": ["id", "title"],
+ "properties": {
+ "id": { "$ref": "#/$defs/CategoryId" },
+ "title": { "type": "string" },
+ "desc": { "type": "string" },
+ "isMetadata": { "type": "boolean", "description": "True for the 'solution-desc' metadata category." },
+ "metadata": {
+ "$ref": "#/$defs/SolutionMetadata",
+ "description": "Filled only when isMetadata is true (solution-desc category)."
+ },
+ "entries": {
+ "type": "array",
+ "description": "Answered entries within this category.",
+ "items": { "$ref": "#/$defs/QuestionnaireEntry" }
+ }
+ }
+ },
+ "SolutionMetadata": {
+ "type": "object",
+ "description": "Descriptive metadata for the assessed solution (solution-desc category).",
+ "properties": {
+ "productName": { "type": "string" },
+ "company": { "type": "string" },
+ "department": { "type": "string" },
+ "contactPerson": { "type": "string" },
+ "description": { "type": "string" },
+ "executionType": {
+ "type": "string",
+ "description": "Execution model of the software. Controls which questionnaire entries are applicable.",
+ "enum": [
+ "Not specified",
+ "Web Application",
+ "Desktop Application",
+ "Mobile Application",
+ "Headless Service / API",
+ "Background Worker / Daemon",
+ "Embedded / IoT"
+ ]
+ },
+ "architecturalRole": {
+ "type": "string",
+ "description": "Role within the system architecture. Controls which questionnaire entries are applicable.",
+ "enum": [
+ "Not specified",
+ "Standalone System",
+ "Domain Service / Microservice",
+ "Integration Bridge / Middleware",
+ "Add-on / Plugin",
+ "AI / ML Inference Engine"
+ ]
+ }
+ }
+ },
+ "QuestionnaireEntry": {
+ "type": "object",
+ "description": "A single answered entry within a questionnaire category.",
+ "required": ["id", "aspect"],
+ "properties": {
+ "id": { "$ref": "#/$defs/EntryId" },
+ "aspect": { "type": "string", "description": "Human-readable aspect label (e.g. 'High-Level Pattern', 'Tech Stack & Runtime')." },
+ "applicability": {
+ "type": "string",
+ "description": "Whether this entry applies to the assessed solution.",
+ "enum": ["applicable", "not-applicable", "conditional"]
+ },
+ "entryComment": { "type": "string", "description": "Free-text comment about applicability or context." },
+ "answers": {
+ "type": "array",
+ "description": "One or more technology or practice answers for this aspect.",
+ "items": { "$ref": "#/$defs/EntryAnswer" }
+ }
+ }
+ },
+ "EntryAnswer": {
+ "type": "object",
+ "description": "A single technology or practice answer within an entry.",
+ "required": ["technology", "status"],
+ "properties": {
+ "technology": { "type": "string", "description": "Technology, tool, or practice name (e.g. 'Vue.js', 'Entity Framework', 'Docker')." },
+ "status": { "$ref": "#/$defs/Status" },
+ "comments": { "type": "string", "description": "Optional rationale or additional context for this answer." }
+ }
+ },
+ "Status": {
+ "type": "string",
+ "description": "Tech-radar ring position. Adopt = recommended standard; Trial = being tested in production; Assess = under evaluation; Hold = discouraged for new work; Retire = actively being removed.",
+ "enum": ["Adopt", "Trial", "Assess", "Hold", "Retire"]
+ },
+ "CategoryId": {
+ "type": "string",
+ "description": "Top-level questionnaire/radar category identifier.",
+ "enum": [
+ "solution-desc",
+ "architecture",
+ "frontend",
+ "backend",
+ "infra-data",
+ "ops",
+ "security",
+ "hardware-io",
+ "qa-testing"
+ ]
+ },
+ "EntryId": {
+ "type": "string",
+ "description": "Questionnaire entry identifier. Valid IDs per category β architecture: arch-hlp (High-Level Pattern), arch-protocols (Communication Protocols), arch-state (Application State Model), arch-data-pattern (Data Architecture Patterns), arch-ownership (Data Ownership), arch-tenancy (Multi-Tenancy Model), arch-datetime (Date & Time Representation), arch-res (Resilience Patterns), arch-failure (Failure Domains), arch-off (Offline Capability), arch-decompose (Service Decomposition), arch-integration (Integration Pattern), arch-consistency (Consistency Model), arch-extensibility (Extensibility Model), arch-feature (Feature Strategy), arch-dep (Dependency Management), arch-topology (Deployment Topology), arch-cloud (Cloud Strategy & Dependency), arch-ai (AI Integration Pattern) | frontend: fe-clientos (Client OS), fe-apptype (App Type & Stack), fe-state (State Management), fe-complib (Component Library), fe-styling (UI Styling / Theming), fe-cache (Client Caching Strategy), fe-error (Client Error Handling), fe-logging (Client Logging), fe-a11y (Accessibility), fe-i18n (Internationalization), fe-analytics (Client Analytics), fe-build (Frontend Build System), fe-quality (Code Quality & Formatting), fe-unittest (Frontend Unit Testing), fe-dev-env (Development Environment) | backend: be-runtime (Tech Stack & Runtime), fe-runtimeos (Runtime OS), be-api-doc (API Documentation), be-api-versioning (API Versioning), be-dal (Data Access Layer), be-ioc (IoC Container), be-cache (Server-Side Caching), be-jobs (Job Scheduling), be-workflow (Workflow Engine), be-error (Backend Error Handling), be-logging (Backend Logging Framework), be-unittest (Backend Unit Testing), be-integration (Backend Integration Testing), be-perf (Code-Level Profiling), be-dev-env (Development Environment) | infra-data: infra-rdbms (Relational Database), infra-schema (Schema Migration Management), infra-nosql (NoSQL Databases), infra-vectordb (Vector Database / Search), infra-middleware (Message Bus & Integration Middleware), infra-dataplatform (Data Platform & Warehousing), infra-analytics (Data Analytics & Visualization), infra-cloud-services (Managed Cloud Services) | ops: ops-deploy (Deployment Artifact), ops-update (Update Mechanism), ops-config (Configuration Management), ops-resources (Resource Constraints), ops-remote (Remote Support Access), ops-webserver (Webserver / Reverse Proxy), ops-virtualization (Hardware Virtualization), ops-container-runtime (Container Runtime), ops-orchestration (Container Orchestration), ops-registry (Artifact / Container Registry), ops-iac (Infrastructure as Code), ops-cicd (CI/CD Pipeline), ops-log-aggregation (Log Aggregation), ops-metrics (Metrics & Alerting), ops-tracing (Distributed Tracing), ops-dr (Disaster Recovery & SLAs), ops-backup (Backup Strategy), ops-retention (Data Retention & Archiving) | security: sec-authn (Authentication), sec-authz (Authorization), sec-secrets (Secret Management), sec-encryption (Encryption), sec-vuln-scan (Vulnerability Scanning), sec-audit (Audit & Compliance Logging), sec-licensing (Licensing & Usage Enforcement) | hardware-io: hw-communication (Communication Patterns), hw-driver (Driver Abstraction), hw-buffering (Data Buffering Strategy), hw-realtime (Real-Time Requirements), hw-lifecycle (Connection Lifecycle) | qa-testing: qa-testcase (Test Case Management), qa-traceability (Requirement Traceability), qa-code-quality (Code Quality & Coverage), qa-integration (Integration & API Testing), qa-e2e (End-to-End Testing), qa-performance (Performance & Load Testing), qa-testdata (Test Data Management)",
+ "enum": [
+ "arch-hlp", "arch-protocols", "arch-state", "arch-data-pattern", "arch-ownership",
+ "arch-tenancy", "arch-datetime", "arch-res", "arch-failure", "arch-off",
+ "arch-decompose", "arch-integration", "arch-consistency", "arch-extensibility",
+ "arch-feature", "arch-dep", "arch-topology", "arch-cloud", "arch-ai",
+ "fe-clientos", "fe-apptype", "fe-state", "fe-complib", "fe-styling",
+ "fe-cache", "fe-error", "fe-logging", "fe-a11y", "fe-i18n",
+ "fe-analytics", "fe-build", "fe-quality", "fe-unittest", "fe-dev-env",
+ "be-runtime", "fe-runtimeos", "be-api-doc", "be-api-versioning", "be-dal",
+ "be-ioc", "be-cache", "be-jobs", "be-workflow", "be-error",
+ "be-logging", "be-unittest", "be-integration", "be-perf", "be-dev-env",
+ "infra-rdbms", "infra-schema", "infra-nosql", "infra-vectordb", "infra-middleware",
+ "infra-dataplatform", "infra-analytics", "infra-cloud-services",
+ "ops-deploy", "ops-update", "ops-config", "ops-resources", "ops-remote",
+ "ops-webserver", "ops-virtualization", "ops-container-runtime", "ops-orchestration",
+ "ops-registry", "ops-iac", "ops-cicd", "ops-log-aggregation", "ops-metrics",
+ "ops-tracing", "ops-dr", "ops-backup", "ops-retention",
+ "sec-authn", "sec-authz", "sec-secrets", "sec-encryption", "sec-vuln-scan",
+ "sec-audit", "sec-licensing",
+ "hw-communication", "hw-driver", "hw-buffering", "hw-realtime", "hw-lifecycle",
+ "qa-testcase", "qa-traceability", "qa-code-quality", "qa-integration",
+ "qa-e2e", "qa-performance", "qa-testdata"
+ ]
+ }
+ }
+}
+""";
+
+ private const string QuestionnaireSchemaJson = """
+{
+ "$schema": "https://json-schema.org/draft-07/schema#",
+ "title": "SolutionInventory Questionnaire",
+ "description": "A single technology-assessment questionnaire document.",
+ "type": "object",
+ "required": ["id", "name", "categories"],
+ "properties": {
+ "id": { "type": "string", "description": "Unique questionnaire identifier." },
+ "name": { "type": "string", "description": "Display name β usually the component or solution name." },
+ "categories": {
+ "type": "array",
+ "description": "Ordered list of category responses. Starts with 'solution-desc' (metadata), followed by applicable technology categories.",
+ "items": { "$ref": "#/$defs/QuestionnaireCategory" }
+ }
+ },
+ "$defs": {
+ "QuestionnaireCategory": {
+ "type": "object",
+ "required": ["id", "title"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Category identifier.",
+ "enum": [
+ "solution-desc", "architecture", "frontend", "backend",
+ "infra-data", "ops", "security", "hardware-io", "qa-testing"
+ ]
+ },
+ "title": { "type": "string" },
+ "desc": { "type": "string" },
+ "isMetadata": {
+ "type": "boolean",
+ "description": "True for the 'solution-desc' category. When true, use 'metadata' instead of 'entries'."
+ },
+ "metadata": { "$ref": "#/$defs/SolutionMetadata" },
+ "entries": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/QuestionnaireEntry" }
+ }
+ }
+ },
+ "SolutionMetadata": {
+ "type": "object",
+ "description": "Solution description metadata for the 'solution-desc' category.",
+ "properties": {
+ "productName": { "type": "string" },
+ "company": { "type": "string" },
+ "department": { "type": "string" },
+ "contactPerson": { "type": "string" },
+ "description": { "type": "string" },
+ "executionType": {
+ "type": "string",
+ "description": "Execution model β controls which questionnaire entries are shown.",
+ "enum": [
+ "Not specified", "Web Application", "Desktop Application",
+ "Mobile Application", "Headless Service / API",
+ "Background Worker / Daemon", "Embedded / IoT"
+ ]
+ },
+ "architecturalRole": {
+ "type": "string",
+ "description": "Architectural role β controls which questionnaire entries are shown.",
+ "enum": [
+ "Not specified", "Standalone System", "Domain Service / Microservice",
+ "Integration Bridge / Middleware", "Add-on / Plugin", "AI / ML Inference Engine"
+ ]
+ }
+ }
+ },
+ "QuestionnaireEntry": {
+ "type": "object",
+ "required": ["id", "aspect"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Entry identifier. See workspace schema $defs/EntryId for the full list of valid IDs per category.",
+ "examples": ["arch-hlp", "be-runtime", "fe-apptype", "ops-deploy", "sec-authn"]
+ },
+ "aspect": { "type": "string", "description": "Human-readable aspect label." },
+ "applicability": {
+ "type": "string",
+ "enum": ["applicable", "not-applicable", "conditional"]
+ },
+ "entryComment": { "type": "string" },
+ "answers": {
+ "type": "array",
+ "description": "One or more technology or practice answers.",
+ "minItems": 1,
+ "items": { "$ref": "#/$defs/EntryAnswer" }
+ }
+ }
+ },
+ "EntryAnswer": {
+ "type": "object",
+ "required": ["technology", "status"],
+ "properties": {
+ "technology": { "type": "string", "description": "Technology, tool, or practice name." },
+ "status": {
+ "type": "string",
+ "description": "Tech-radar position for this technology.",
+ "enum": ["Adopt", "Trial", "Assess", "Hold", "Retire"]
+ },
+ "comments": { "type": "string" }
+ }
+ }
+ }
+}
+""";
+}
diff --git a/MCP/McpServer/Services/McpSessionManager.cs b/MCP/McpServer/Services/McpSessionManager.cs
index 4f03c76..9ccf347 100644
--- a/MCP/McpServer/Services/McpSessionManager.cs
+++ b/MCP/McpServer/Services/McpSessionManager.cs
@@ -2,6 +2,7 @@
using System.Text;
using System.Text.Json.Nodes;
using McpServer.Data;
+using McpServer.Logic;
using Microsoft.AspNetCore.Http.Features;
namespace McpServer.Services;
@@ -33,15 +34,17 @@ private sealed class Session(string id, Func send)
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private readonly ConcurrentDictionary _sessions = new();
- private readonly ConfigService _config;
- private readonly LogBroadcaster _log;
- private readonly ProjectRepository _repo;
+ private readonly ConfigService _config;
+ private readonly LogBroadcaster _log;
+ private readonly ProjectRepository _repo;
+ private readonly QuestionnaireEvaluator _evaluator;
- public McpSessionManager(ConfigService config, LogBroadcaster log, ProjectRepository repo)
+ public McpSessionManager(ConfigService config, LogBroadcaster log, ProjectRepository repo, QuestionnaireEvaluator evaluator)
{
- _config = config;
- _log = log;
- _repo = repo;
+ _config = config;
+ _log = log;
+ _repo = repo;
+ _evaluator = evaluator;
}
// ββ SSE endpoint GET /sse ββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -310,7 +313,7 @@ private static string BuildToolListResponse(JsonNode id) =>
new JsonObject
{
["name"] = "get_tech_radar",
- ["description"] = "Returns the project-level tech radar: all technology overrides (with status 'adopt', 'trial', 'hold', or 'retire' and optional comments), the questionnaire references that contributed each item, and the radar category order.",
+ ["description"] = "Returns the project-level tech radar: all radar entries (with option, category, status, shortComment and description), grouped by status ring, and the radar category order.",
["inputSchema"] = new JsonObject
{
["type"] = "object",
@@ -335,6 +338,25 @@ private static string BuildToolListResponse(JsonNode id) =>
},
["required"] = new JsonArray { "questionnaire_id" }
}
+ },
+ new JsonObject
+ {
+ ["name"] = "get_json_schema",
+ ["description"] = "Returns a JSON Schema (Draft-07) document describing a SolutionInventory data format. Use 'workspace' to get the schema for the complete workspace export file (project + questionnaires, including all category IDs, entry IDs, and valid enum values). Use 'questionnaire' to get the schema for a single standalone questionnaire document.",
+ ["inputSchema"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = new JsonObject
+ {
+ ["type"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = "The schema to retrieve: 'workspace' (full export format with project + questionnaires) or 'questionnaire' (single questionnaire document).",
+ ["enum"] = new JsonArray { "workspace", "questionnaire" }
+ }
+ },
+ ["required"] = new JsonArray { "type" }
+ }
}
}
});
@@ -354,6 +376,7 @@ private async Task BuildToolCallResponseAsync(JsonNode id, JsonNode? @pa
"get_answers_for_category" => BuildAnswersForCategoryResponse(id, args, excludedIds),
"get_tech_radar" => BuildTechRadarResponse(id),
"evaluate_responses" => BuildEvaluateResponsesResponse(id, args),
+ "get_json_schema" => BuildGetJsonSchemaResponse(id, args),
_ => BuildError(id, -32602, $"Unknown tool: {toolName}")
};
}
@@ -490,39 +513,26 @@ private string BuildTechRadarResponse(JsonNode id)
sb.AppendLine($"**Category order:** {string.Join(" βΊ ", radar.CategoryOrder)}");
sb.AppendLine();
- // Group overrides by status ring for a radar-style overview
- var rings = new[] { "adopt", "trial", "hold", "retire" };
- foreach (var ring in rings)
+ foreach (var group in radar.Entries.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase))
{
- var items = radar.Overrides
- .Where(o => o.Status.Equals(ring, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- if (items.Count == 0) continue;
+ var ring = group.Key;
+ var items = group.ToList();
sb.AppendLine($"## {ring.ToUpper()} ({items.Count})");
foreach (var item in items)
{
- var cat = string.IsNullOrWhiteSpace(item.CategoryOverride) ? item.EntryId : item.CategoryOverride;
- sb.Append($" - **{item.Option}** [{cat}]");
+ sb.Append($" - **{item.Option}** [{item.Category}]");
if (!string.IsNullOrWhiteSpace(item.ShortComment)) sb.Append($" β {item.ShortComment}");
sb.AppendLine();
- if (!string.IsNullOrWhiteSpace(item.Comment))
+ if (!string.IsNullOrWhiteSpace(item.Description))
{
- foreach (var line in item.Comment.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ foreach (var line in item.Description.Split('\n', StringSplitOptions.RemoveEmptyEntries))
sb.AppendLine($" > {line.Trim()}");
}
}
sb.AppendLine();
}
- // Questionnaire references summary
- sb.AppendLine($"## Questionnaire References ({radar.Refs.Count} total)");
- foreach (var byEntry in radar.Refs.GroupBy(r => r.EntryId))
- {
- sb.AppendLine($" - `{byEntry.Key}`: {string.Join(", ", byEntry.Select(r => r.Option))}");
- }
-
return BuildTextToolResponse(id, sb.ToString());
}
@@ -532,14 +542,16 @@ private string BuildEvaluateResponsesResponse(JsonNode id, JsonNode? args)
if (string.IsNullOrWhiteSpace(questionnaireId))
return BuildError(id, -32602, "Parameter 'questionnaire_id' is required.");
- var result = _repo.EvaluateResponses(questionnaireId);
- if (result is null)
+ var questionnaire = _repo.FindQuestionnaire(questionnaireId);
+ if (questionnaire is null)
{
return BuildTextToolResponse(id, _repo.IsLoaded
? $"No questionnaire found with ID or name '{questionnaireId}'."
: NotLoadedMessage);
}
+ var result = _evaluator.Evaluate(questionnaire);
+
var warningsArray = new JsonArray();
foreach (var w in result.Warnings)
warningsArray.Add(JsonValue.Create(w));
@@ -554,6 +566,13 @@ private string BuildEvaluateResponsesResponse(JsonNode id, JsonNode? args)
return BuildTextToolResponse(id, json.ToJsonString());
}
+ private static string BuildGetJsonSchemaResponse(JsonNode id, JsonNode? args)
+ {
+ var type = args?["type"]?.GetValue() ?? "workspace";
+ var schema = type == "questionnaire" ? JsonSchemas.QuestionnaireSchema : JsonSchemas.WorkspaceSchema;
+ return BuildTextToolResponse(id, $"```json\n{schema}\n```");
+ }
+
private const string NotLoadedMessage =
"No workspace loaded. Use the management UI (Workspace tab β Load Example or Load File) to load a workspace first.";
diff --git a/MCP/McpServer/config.json b/MCP/McpServer/config.json
index 79323b4..d3dd7de 100644
--- a/MCP/McpServer/config.json
+++ b/MCP/McpServer/config.json
@@ -2,9 +2,6 @@
"access_enabled": true,
"data_source": "local",
"data_source_url": "",
- "excluded_questionnaire_ids": [
- "questionnaire-8hocc331",
- "questionnaire-tccm5w35"
- ],
- "reference_questionnaire_id": "questionnaire-tm37szzw"
+ "excluded_questionnaire_ids": [],
+ "reference_questionnaire_id": "questionnaire-8hocc331"
}
\ No newline at end of file
diff --git a/MCP/McpServer/wwwroot/index.html b/MCP/McpServer/wwwroot/index.html
index 7797e94..1dd72a0 100644
--- a/MCP/McpServer/wwwroot/index.html
+++ b/MCP/McpServer/wwwroot/index.html
@@ -331,7 +331,6 @@
Request Log
-
@@ -393,7 +392,15 @@
Request Log
get_tech_radar
- Returns the tech radar with adopt / trial / hold / retire classifications and comments.
+ Returns the tech radar with technology classifications and comments, grouped by the statuses present in the data.
+
+
+ evaluate_responses
+ Evaluates a questionnaire by questionnaire_id. Returns a consistency score (0.0β1.0), a completeness % (0β100), and a list of detected issues such as missing metadata, unanswered entries, or conflicting technology statuses.
+
+
+ get_json_schema
+ Returns a JSON Schema (Draft-07) for a SolutionInventory data format. Pass type = "workspace" for the full export format (project + questionnaires, including all category/entry IDs and enum values) or "questionnaire" for a single standalone questionnaire document.