From a988d7b999ae643bdd2b2e8c9913483c059da133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 17 Mar 2026 10:40:27 +0100 Subject: [PATCH 01/10] chore: restructured build for better releases --- .github/workflows/ci-dev.yml | 78 +++++--------------------- .github/workflows/electron-release.yml | 47 ++++++++++++---- 2 files changed, 51 insertions(+), 74 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index f508621..05d6f0e 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -1,73 +1,23 @@ -name: CI – Dev Branch +name: Manual Pre-release on: - push: - branches: - - Dev + workflow_dispatch: + inputs: + target_branch: + description: Branch used for pre-release build/version bump + required: true + default: Dev permissions: contents: write jobs: # ------------------------------------------------------------------ - # 1. Run all E2E tests - # ------------------------------------------------------------------ - test: - name: E2E Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: Client/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: Client - - - name: Install Playwright browser and system dependencies - run: npx playwright install --with-deps chromium - working-directory: Client - - - name: Start dev server - run: nohup npx vite --host > /tmp/vite.log 2>&1 & - working-directory: Client - - - name: Wait for dev server - run: | - echo "Waiting for Vite dev server..." - for i in $(seq 1 30); do - if curl -sf http://localhost:5173/SolutionInventory/ > /dev/null 2>&1; then - echo "Server is up after ${i} attempts" - exit 0 - fi - echo "Attempt $i failed, retrying in 2s..." - sleep 2 - done - echo "--- Vite log ---" - cat /tmp/vite.log - echo "Server did not respond after 60s" - exit 1 - - - name: Run E2E tests - run: npx cucumber-js - working-directory: Client - - # ------------------------------------------------------------------ - # 2. Bump the pre-release version, commit it back to Dev and tag it + # 1. Bump the pre-release version, commit it back and tag it # ------------------------------------------------------------------ version: name: Bump Pre-release Version - needs: test runs-on: ubuntu-latest - # Prevent infinite loop when this workflow commits the version bump - if: "!contains(github.event.head_commit.message, '[skip ci]')" outputs: version: ${{ steps.bump.outputs.version }} @@ -75,6 +25,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ inputs.target_branch }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -103,12 +54,12 @@ jobs: git add package.json package-lock.json git commit -m "chore: pre-release v${VERSION} [skip ci]" git tag "v${VERSION}" - git push origin Dev + git push origin "${{ inputs.target_branch }}" git push origin "v${VERSION}" working-directory: Client # ------------------------------------------------------------------ - # 3. Build the Electron app for all platforms and publish artifacts + # 2. Build the Electron app for all platforms and publish artifacts # ------------------------------------------------------------------ build-electron: name: Build Electron (${{ matrix.artifact_name }}) @@ -179,7 +130,7 @@ jobs: path: Client/release/*.exe # ------------------------------------------------------------------ - # 4. Create a GitHub Pre-release with all Electron artifacts + # 3. Create a GitHub Pre-release with all Electron artifacts # ------------------------------------------------------------------ create-prerelease: name: Create GitHub Pre-release @@ -198,9 +149,10 @@ jobs: tag_name: v${{ needs.version.outputs.version }} name: "Pre-release v${{ needs.version.outputs.version }}" body: | - 🚧 **Pre-release v${{ needs.version.outputs.version }}** + Pre-release v${{ needs.version.outputs.version }} - Automatically built from the `Dev` branch. Not intended for production use. + Manually triggered dev pre-release. + Source branch: ${{ inputs.target_branch }} Commit: ${{ github.sha }} prerelease: true files: artifacts/**/* diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 1c01290..d4cc0e9 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -1,9 +1,17 @@ -name: Release on Merge to Main +name: Manual Full Release on: - push: - branches: - - main + workflow_dispatch: + inputs: + release_type: + description: Semantic version bump type + type: choice + options: + - patch + - minor + - major + required: true + default: minor permissions: contents: write @@ -16,13 +24,27 @@ concurrency: jobs: # ------------------------------------------------------------------ - # 1. Bump the minor version, commit it back to main and create a tag + # 0. Ensure this workflow is only run from main + # ------------------------------------------------------------------ + validate-main: + name: Validate Main Branch + runs-on: ubuntu-latest + + steps: + - name: Ensure workflow ref is main + run: | + if [ "${{ github.ref_name }}" != "main" ]; then + echo "This workflow can only run from main. Current ref: ${{ github.ref_name }}" + exit 1 + fi + + # ------------------------------------------------------------------ + # 1. Bump the selected semantic version and create a tag # ------------------------------------------------------------------ version: name: Bump Version + needs: validate-main runs-on: ubuntu-latest - # Prevent infinite loop: skip if the commit was created by this workflow - if: "!contains(github.event.head_commit.message, '[skip ci]')" outputs: version: ${{ steps.bump.outputs.version }} @@ -30,6 +52,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: main fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -49,10 +72,10 @@ jobs: git config user.email "ci@github-actions" git config user.name "GitHub Actions" - - name: Bump minor version and push + - name: Bump selected version and push id: bump run: | - npm version minor --no-git-tag-version + npm version ${{ inputs.release_type }} --no-git-tag-version VERSION=$(node -p "require('./package.json').version") echo "version=${VERSION}" >> $GITHUB_OUTPUT git add package.json package-lock.json @@ -153,9 +176,11 @@ jobs: tag_name: v${{ needs.version.outputs.version }} name: "Release v${{ needs.version.outputs.version }}" body: | - ?? **Release v${{ needs.version.outputs.version }}** + Release v${{ needs.version.outputs.version }} - Automatically built and released on merge to `main`. + Manually triggered full release. + Source branch: main + Release type: ${{ inputs.release_type }} Commit: ${{ github.sha }} prerelease: false files: artifacts/**/* From 784b39abe27933eb8bdc099400d38e49650de7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 17 Mar 2026 15:19:29 +0100 Subject: [PATCH 02/10] chore: fixed DEV build --- .github/workflows/ci-dev.yml | 135 ++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 05d6f0e..e795c49 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -1,6 +1,9 @@ -name: Manual Pre-release +name: Dev CI and Manual Pre-release on: + push: + branches: + - Dev workflow_dispatch: inputs: target_branch: @@ -12,11 +15,139 @@ permissions: contents: write jobs: + # ------------------------------------------------------------------ + # CI on push to Dev: test and build only (no deployment) + # ------------------------------------------------------------------ + test-dev: + name: E2E Tests (Dev Push) + if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: Client + + - name: Install Playwright browser and system dependencies + run: npx playwright install --with-deps chromium + working-directory: Client + + - name: Start dev server + run: nohup npx vite --host > /tmp/vite.log 2>&1 & + working-directory: Client + + - name: Wait for dev server + run: | + echo "Waiting for Vite dev server..." + for i in $(seq 1 30); do + if curl -sf http://localhost:5173/SolutionInventory/ > /dev/null 2>&1; then + echo "Server is up after ${i} attempts" + exit 0 + fi + echo "Attempt $i failed, retrying in 2s..." + sleep 2 + done + echo "--- Vite log ---" + cat /tmp/vite.log + echo "Server did not respond after 60s" + exit 1 + + - name: Run E2E tests + run: npx cucumber-js + working-directory: Client + + build-web-dev: + name: Build Web App (Dev Push) + if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') + needs: test-dev + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: Client + + - name: Build web application + run: npx vite build + working-directory: Client + + build-electron-dev: + name: Build Electron (Dev Push - ${{ matrix.artifact_name }}) + if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') + needs: test-dev + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact_name: linux-x64 + electron_flags: --linux --x64 + - os: windows-latest + artifact_name: windows-x64 + electron_flags: --win --x64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/package-lock.json + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libx11-xcb1 libxrandr2 libxcomposite1 libxcursor1 libxdamage1 \ + libxfixes3 libxi6 libgtk-3-0t64 libatk1.0-0t64 libcairo-gobject2 \ + libgdk-pixbuf-2.0-0 libasound2t64 libgtk-4-1 libvulkan1 libopus0 \ + libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \ + libgstreamer-plugins-bad1.0-0 libflite1 libwebp7 libharfbuzz-icu0 \ + libwayland-server0 libmanette-0.2-0 libenchant-2-2 libgbm1 libdrm2 \ + libhyphen0 libgles2 rpm fakeroot dpkg + + - name: Install dependencies + run: npm ci + working-directory: Client + + - name: Build Vite + Electron app + run: npx vite build --mode electron && npx electron-builder ${{ matrix.electron_flags }} --publish never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: Client + # ------------------------------------------------------------------ # 1. Bump the pre-release version, commit it back and tag it # ------------------------------------------------------------------ version: name: Bump Pre-release Version + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: version: ${{ steps.bump.outputs.version }} @@ -63,6 +194,7 @@ jobs: # ------------------------------------------------------------------ build-electron: name: Build Electron (${{ matrix.artifact_name }}) + if: github.event_name == 'workflow_dispatch' needs: version runs-on: ${{ matrix.os }} @@ -134,6 +266,7 @@ jobs: # ------------------------------------------------------------------ create-prerelease: name: Create GitHub Pre-release + if: github.event_name == 'workflow_dispatch' needs: [version, build-electron] runs-on: ubuntu-latest From c6be593f07da307fdd71f934e6c450e422e79d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 17 Mar 2026 15:32:51 +0100 Subject: [PATCH 03/10] fix: fixed logo link in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de2937d..abd033e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Solution Inventory

- Solution Inventory + Solution Inventory

Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application for **Windows** and **Linux**. The Electron app stores all data locally on the device (no cloud sync). The web version (hosted via GitHub Pages) stores all data exclusively in the browser's Local Storage (no server-side storage or sync). It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog. From e833b82ec5652a2a24109399b6015a6514816e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 17 Mar 2026 17:20:21 +0100 Subject: [PATCH 04/10] fix: fixed broken UI elements for MCP server and wrong tech radar. --- MCP/McpServer/Data/ProjectRepository.cs | 176 +----------------- MCP/McpServer/Logic/QuestionnaireEvaluator.cs | 150 +++++++++++++++ MCP/McpServer/Program.cs | 9 +- MCP/McpServer/Services/McpSessionManager.cs | 35 ++-- MCP/McpServer/wwwroot/index.html | 28 +-- mcp-bridge.js | 14 ++ 6 files changed, 196 insertions(+), 216 deletions(-) create mode 100644 MCP/McpServer/Logic/QuestionnaireEvaluator.cs diff --git a/MCP/McpServer/Data/ProjectRepository.cs b/MCP/McpServer/Data/ProjectRepository.cs index a7a1687..246335b 100644 --- a/MCP/McpServer/Data/ProjectRepository.cs +++ b/MCP/McpServer/Data/ProjectRepository.cs @@ -14,23 +14,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; } @@ -102,21 +92,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( @@ -268,156 +243,15 @@ private static IEnumerable FilterQuestionnaires( p.RadarCategoryOrder.AsReadOnly()); } - // ── Quality assessment ──────────────────────────────────────────────────── + // ── Questionnaire lookup ────────────────────────────────────────────────── - /// - /// 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) + /// 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; - - var questionnaire = _workspace.Questionnaires.FirstOrDefault(q => + return _workspace.Questionnaires.FirstOrDefault(q => q.Id.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase) || q.Name.Equals(questionnaireId, StringComparison.OrdinalIgnoreCase)); - - if (questionnaire is null) return null; - - 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()); } // ── 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/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/McpSessionManager.cs b/MCP/McpServer/Services/McpSessionManager.cs index 4f03c76..96c8010 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 ──────────────────────────────────────────────── @@ -490,15 +493,11 @@ 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) + // Group overrides by their actual status values from the data + foreach (var group in radar.Overrides.GroupBy(o => o.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) @@ -532,14 +531,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)); diff --git a/MCP/McpServer/wwwroot/index.html b/MCP/McpServer/wwwroot/index.html index 7797e94..95ee4d3 100644 --- a/MCP/McpServer/wwwroot/index.html +++ b/MCP/McpServer/wwwroot/index.html @@ -331,7 +331,6 @@

Request Log

-
@@ -393,7 +392,11 @@

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.
@@ -551,7 +554,6 @@

Request Log

// ── Workspace ───────────────────────────────────────────────────────────── const wsFileInput = document.getElementById('ws-file-input'); const wsLoadBtn = document.getElementById('ws-load-btn'); - const wsExampleBtn = document.getElementById('ws-example-btn'); const wsFeedback = document.getElementById('ws-feedback'); const wsSummary = document.getElementById('ws-summary'); const wsStats = document.getElementById('ws-stats'); @@ -639,7 +641,7 @@

Request Log

wsFileInput.addEventListener('change', async () => { const file = wsFileInput.files[0]; if (!file) return; - wsLoadBtn.disabled = wsExampleBtn.disabled = true; + wsLoadBtn.disabled = true; try { const form = new FormData(); form.append('file', file); @@ -651,23 +653,7 @@

Request Log

} catch (e) { showFeedback('Upload failed: ' + e.message, true); } finally { - wsLoadBtn.disabled = wsExampleBtn.disabled = false; - } - }); - - // Load built-in example - wsExampleBtn.addEventListener('click', async () => { - wsLoadBtn.disabled = wsExampleBtn.disabled = true; - try { - const res = await fetch('/api/workspace/load-example', { method: 'POST' }); - const data = await res.json(); - if (!res.ok) { showFeedback(data.message, true); return; } - showFeedback(data.message, false); - await refreshSummary(); - } catch (e) { - showFeedback('Request failed: ' + e.message, true); - } finally { - wsLoadBtn.disabled = wsExampleBtn.disabled = false; + wsLoadBtn.disabled = false; } }); diff --git a/mcp-bridge.js b/mcp-bridge.js index 30ca840..5dcd5f0 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -148,6 +148,20 @@ async function handleListTools(params, id) { properties: {}, required: [] } + }, + { + name: 'evaluate_responses', + description: 'Evaluates response quality of a questionnaire. Returns consistency_score (0.0-1.0), completeness_% (0-100), and a warnings array with detected issues.', + inputSchema: { + type: 'object', + properties: { + questionnaire_id: { + type: 'string', + description: 'The unique identifier (ID or name) of the questionnaire to evaluate.' + } + }, + required: ['questionnaire_id'] + } } ]; From 8f6361cad9c42c0ef001a09f6d46aac762826db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 17 Mar 2026 17:22:12 +0100 Subject: [PATCH 05/10] chore: updated todos --- docs/todos.md | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/docs/todos.md b/docs/todos.md index 2d12f90..a10d772 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -7,28 +7,7 @@ --- -## 2. Core Evaluation Functions - -### 2.1 Response Quality Assessment -- [ ] **Create `evaluate_responses(questionnaire_id: str)` function** - - **Input**: Unique identifier for a questionnaire (`questionnaire_id` as string) - - **Output**: JSON object containing: - - `consistency_score` (float, 0.0–1.0): measures internal consistency of responses - - `completeness_%` (float, 0–100): percentage of completed mandatory fields - - `warnings` (array of strings): list of detected issues or anomalies - -### 2.2 Data Export -- [ ] **Create `export_cleaned_data(questionnaire_id: str, output_format: str)` function** - - **Input**: - - `questionnaire_id` (string): identifier of the questionnaire to export - - `output_format` (enum: `"json"` or `"csv"`): desired export file format - - **Output**: JSON object containing: - - `filepath` (string): absolute or relative path to the exported file - - `size_mb` (float): file size in megabytes - ---- - -## 3. Data Cleaning & Validation +## 2. Data Cleaning & Validation - [ ] **Implement data cleaning function to detect naming inconsistencies** - Scan all field names, category labels, and identifiers for: @@ -42,9 +21,17 @@ - Validate all TechRadar entries against this whitelist - Flag or auto-correct deviations from approved status terminology +- [ ] **Create `export_cleaned_data(questionnaire_id: str, output_format: str)` function** + - **Input**: + - `questionnaire_id` (string): identifier of the questionnaire to export + - `output_format` (enum: `"json"` or `"csv"`): desired export file format + - **Output**: JSON object containing: + - `filepath` (string): absolute or relative path to the exported file + - `size_mb` (float): file size in megabytes + --- -## 4. Intelligent Analysis Features +## 3. Intelligent Analysis Features - [ ] **Calculate consistency score for all responses** - Analyze cross-field logical consistency (e.g., contradictory answers) @@ -57,7 +44,7 @@ --- -## 5. Documentation & AI Interpretability +## 4. Documentation & AI Interpretability - [ ] **Revise all function descriptions for improved AI comprehension** - Use clear, structured docstrings with: @@ -69,7 +56,7 @@ --- -## 6. Comparative Analysis +## 5. Comparative Analysis - [ ] **Implement questionnaire comparison against reference baseline** - **Input**: Target questionnaire ID + reference questionnaire ID (or template) @@ -81,7 +68,7 @@ --- -## 7. Reporting & Visualization +## 6. Reporting & Visualization - [ ] **Generate HTML report with evaluation results and differences** - Include: From 8c9c3314f7dba3e41081cc035d0f6067ea2136dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Thu, 19 Mar 2026 15:40:15 +0100 Subject: [PATCH 06/10] feat: migrate tech radar to unified format and update related data structures to allow the mcp better results for the ai --- Client/src/components/projects/TechRadar.vue | 84 +++++++------- Client/src/stores/workspaceStore.js | 111 +++++++++++++++---- MCP/McpServer/Data/ProjectRepository.cs | 22 +++- MCP/McpServer/Models/ProjectData.cs | 4 + MCP/McpServer/Models/RadarEntry.cs | 24 ++++ MCP/McpServer/Models/WorkspaceQueryModels.cs | 3 +- MCP/McpServer/Services/McpSessionManager.cs | 19 +--- 7 files changed, 177 insertions(+), 90 deletions(-) create mode 100644 MCP/McpServer/Models/RadarEntry.cs diff --git a/Client/src/components/projects/TechRadar.vue b/Client/src/components/projects/TechRadar.vue index 3169019..8d04344 100644 --- a/Client/src/components/projects/TechRadar.vue +++ b/Client/src/components/projects/TechRadar.vue @@ -119,7 +119,7 @@
{{ blip.name }} - mdi-pencil-circle + mdi-pencil-circle
@@ -353,7 +353,7 @@
{{ blip.name }} - mdi-pencil-circle + mdi-pencil-circle
@@ -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..09c74b7 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)) @@ -414,8 +440,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 +490,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 +623,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 +686,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 246335b..9b173f9 100644 --- a/MCP/McpServer/Data/ProjectRepository.cs +++ b/MCP/McpServer/Data/ProjectRepository.cs @@ -232,15 +232,27 @@ 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. 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()); + + // Use the new unified radar array if present + if (p.Radar.Count > 0) + return new TechRadarData(p.Radar.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly()); + + // Fallback: build from legacy radarOverrides for old workspace exports + var migrated = p.RadarOverrides.Select(o => new RadarEntry + { + EntryId = o.EntryId, + Option = o.Option, + Category = string.IsNullOrWhiteSpace(o.CategoryOverride) ? o.EntryId : o.CategoryOverride, + Status = o.Status, + ShortComment = o.ShortComment, + Description = o.Comment + }).ToList(); + return new TechRadarData(migrated.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly()); } // ── Questionnaire lookup ────────────────────────────────────────────────── 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/Services/McpSessionManager.cs b/MCP/McpServer/Services/McpSessionManager.cs index 96c8010..9a09f04 100644 --- a/MCP/McpServer/Services/McpSessionManager.cs +++ b/MCP/McpServer/Services/McpSessionManager.cs @@ -313,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", @@ -493,8 +493,7 @@ private string BuildTechRadarResponse(JsonNode id) sb.AppendLine($"**Category order:** {string.Join(" β€Ί ", radar.CategoryOrder)}"); sb.AppendLine(); - // Group overrides by their actual status values from the data - foreach (var group in radar.Overrides.GroupBy(o => o.Status, StringComparer.OrdinalIgnoreCase)) + foreach (var group in radar.Entries.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase)) { var ring = group.Key; var items = group.ToList(); @@ -502,26 +501,18 @@ private string BuildTechRadarResponse(JsonNode id) 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()); } From b752226750407c4072579879b8669b6b1c29a0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Fri, 20 Mar 2026 16:18:59 +0100 Subject: [PATCH 07/10] feat: add JSON Schema support for SolutionInventory data formats and implement related API endpoint for MCP --- MCP/McpServer/Data/ProjectRepository.cs | 171 ++++++++++- MCP/McpServer/Services/JsonSchemas.cs | 324 ++++++++++++++++++++ MCP/McpServer/Services/McpSessionManager.cs | 27 ++ MCP/McpServer/config.json | 7 +- mcp-bridge.js | 15 + 5 files changed, 523 insertions(+), 21 deletions(-) create mode 100644 MCP/McpServer/Services/JsonSchemas.cs diff --git a/MCP/McpServer/Data/ProjectRepository.cs b/MCP/McpServer/Data/ProjectRepository.cs index 9b173f9..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; @@ -42,8 +43,8 @@ public ProjectRepository(ILogger logger) 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."); @@ -72,7 +73,7 @@ public ProjectRepository(ILogger logger) await _sem.WaitAsync(); try { - var ws = JsonSerializer.Deserialize(json, s_opts); + var ws = ParseWorkspaceExport(json); if (ws is null) return (false, "Deserialization returned null."); @@ -232,27 +233,165 @@ private static IEnumerable FilterQuestionnaires( return records.AsReadOnly(); } - /// Returns the tech radar from the loaded project, migrating from legacy format if needed. + /// 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; - // Use the new unified radar array if present + List entries; + if (p.Radar.Count > 0) - return new TechRadarData(p.Radar.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly()); + { + // 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); - // Fallback: build from legacy radarOverrides for old workspace exports - var migrated = p.RadarOverrides.Select(o => new RadarEntry + 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(); + } + + // 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))) { - EntryId = o.EntryId, - Option = o.Option, - Category = string.IsNullOrWhiteSpace(o.CategoryOverride) ? o.EntryId : o.CategoryOverride, - Status = o.Status, - ShortComment = o.ShortComment, - Description = o.Comment - }).ToList(); - return new TechRadarData(migrated.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly()); + var answerLookup = BuildAnswerLookup(_workspace.Questionnaires); + entries = entries.Select(e => + { + if (!string.IsNullOrWhiteSpace(e.Status) && !string.IsNullOrWhiteSpace(e.Category)) + return e; + + var key = $"{e.EntryId}|{e.Option.Trim().ToLowerInvariant()}"; + if (!answerLookup.TryGetValue(key, out var found)) + return e; + + return e with + { + Status = string.IsNullOrWhiteSpace(e.Status) ? found.Status : e.Status, + Category = string.IsNullOrWhiteSpace(e.Category) ? found.Category : e.Category + }; + }).ToList(); + } + + return new TechRadarData(entries.AsReadOnly(), p.RadarCategoryOrder.AsReadOnly()); + } + + // ── 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; + + // Try the full Electron client format: + // { "version": 2, "workspace": { "projects": [...], "questionnaires": [...] } } + try + { + 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) + { + var qId = q?["id"]?.GetValue() ?? string.Empty; + if (questionnaireIds.Count == 0 || questionnaireIds.Contains(qId)) + filteredQuestionnaires.Add(q?.DeepClone()); + } + + var normalised = new JsonObject + { + ["project"] = projectNode.DeepClone(), + ["questionnaires"] = filteredQuestionnaires + }; + + return JsonSerializer.Deserialize(normalised.ToJsonString(), s_opts); + } + catch + { + return standard; + } + } + + /// + /// 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 q in questionnaires) + { + foreach (var cat in q.Categories) + { + if (cat.IsMetadata == true) continue; + foreach (var entry in cat.Entries ?? []) + { + foreach (var answer in entry.Answers ?? []) + { + 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); + } + } + } + } + + return lookup; } // ── Questionnaire lookup ────────────────────────────────────────────────── 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 9a09f04..9ccf347 100644 --- a/MCP/McpServer/Services/McpSessionManager.cs +++ b/MCP/McpServer/Services/McpSessionManager.cs @@ -338,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" } + } } } }); @@ -357,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}") }; } @@ -546,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-bridge.js b/mcp-bridge.js index 5dcd5f0..c04530e 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -162,6 +162,21 @@ async function handleListTools(params, id) { }, required: ['questionnaire_id'] } + }, + { + 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 valid category IDs, entry IDs, and enum values). Use "questionnaire" to get the schema for a single standalone questionnaire document.', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'The schema to retrieve: "workspace" (full export with project + questionnaires) or "questionnaire" (single questionnaire document).', + enum: ['workspace', 'questionnaire'] + } + }, + required: ['type'] + } } ]; From 67b0609527ae4d5711b96740578e8bebcc264b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Tue, 24 Mar 2026 15:36:12 +0100 Subject: [PATCH 08/10] chore: cleanup of mcp-bridge.js --- mcp-bridge.js | 100 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/mcp-bridge.js b/mcp-bridge.js index c04530e..62de922 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -6,11 +6,13 @@ */ const http = require('http'); -const https = require('https'); const readline = require('readline'); +const { spawn } = require('child_process'); +const path = require('path'); const SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:5100'; -let sessionId = null; +const SERVER_DIR = path.join(__dirname, 'MCP', 'McpServer'); +let dotnetProcess = null; // ────────────────────────────────────────────────────────────────────────────── // JSON-RPC Helper @@ -32,17 +34,15 @@ function createError(id, code, message) { // HTTP Helpers // ────────────────────────────────────────────────────────────────────────────── -function makeRequest(path, method = 'GET', body = null) { +function makeRequest(urlPath, method = 'GET', body = null, timeoutMs = 10000) { return new Promise((resolve, reject) => { - const url = new URL(SERVER_URL + path); + const url = new URL(SERVER_URL + urlPath); const options = { hostname: url.hostname, port: url.port, path: url.pathname + url.search, - method: method, - headers: { - 'Content-Type': 'application/json', - } + method, + headers: { 'Content-Type': 'application/json' } }; if (body) { @@ -54,52 +54,68 @@ function makeRequest(path, method = 'GET', body = null) { res.on('data', chunk => data += chunk); res.on('end', () => { try { - const parsed = data ? JSON.parse(data) : {}; - resolve({ status: res.statusCode, data: parsed }); - } catch (e) { + resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} }); + } catch { resolve({ status: res.statusCode, data: { raw: data } }); } }); }); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)); + }); req.on('error', reject); if (body) req.write(body); req.end(); }); } +async function isServerRunning() { + try { + await makeRequest('/', 'GET', null, 2000); + return true; + } catch { + return false; + } +} + +async function ensureServerRunning() { + if (await isServerRunning()) return; + + dotnetProcess = spawn('dotnet', ['run'], { + cwd: SERVER_DIR, + stdio: 'ignore', + detached: false + }); + dotnetProcess.on('error', err => console.error('Failed to start .NET server:', err.message)); + + // Poll until ready (max 30s) + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 1000)); + if (await isServerRunning()) return; + } + console.error('Timeout: .NET server did not become ready within 30s'); +} + // ────────────────────────────────────────────────────────────────────────────── // MCP Initialize // ────────────────────────────────────────────────────────────────────────────── -async function handleInitialize(params, id) { - try { - sessionId = `session-${Date.now()}`; - - const result = { - protocolVersion: '2024-11-05', - capabilities: { - tools: {} - }, - serverInfo: { - name: 'SolutionInventory', - version: '1.0.0' - } - }; - - sendJsonRpc(createResponse(id, result)); - } catch (err) { - sendJsonRpc(createError(id, -32603, err.message)); - } +function handleInitialize(id) { + sendJsonRpc(createResponse(id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'SolutionInventory', version: '1.0.0' } + })); } // ────────────────────────────────────────────────────────────────────────────── // MCP Tools: list_tools // ────────────────────────────────────────────────────────────────────────────── -async function handleListTools(params, id) { - try { - const tools = [ +function handleListTools(id) { + const tools = [ { name: 'list_categories', description: 'Lists all categories in the workspace with their subcategory entries', @@ -180,10 +196,7 @@ async function handleListTools(params, id) { } ]; - sendJsonRpc(createResponse(id, { tools })); - } catch (err) { - sendJsonRpc(createError(id, -32603, err.message)); - } + sendJsonRpc(createResponse(id, { tools })); } // ────────────────────────────────────────────────────────────────────────────── @@ -239,10 +252,10 @@ rl.on('line', async (line) => { switch (method) { case 'initialize': - await handleInitialize(params, id); + handleInitialize(id); break; case 'tools/list': - await handleListTools(params, id); + handleListTools(id); break; case 'tools/call': await handleCallTool(params, id); @@ -258,7 +271,16 @@ rl.on('line', async (line) => { } }); +// Auto-start .NET server before accepting requests +ensureServerRunning().catch(err => console.error('Server startup error:', err.message)); + rl.on('close', () => { + if (dotnetProcess) dotnetProcess.kill(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + if (dotnetProcess) dotnetProcess.kill(); process.exit(0); }); From 6bc5e68fba3a53649cd80b3007bd681d71657d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Wed, 1 Apr 2026 10:30:10 +0200 Subject: [PATCH 09/10] feat: add get_json_schema tool description to the UI for SolutionInventory data formats --- MCP/McpServer/wwwroot/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MCP/McpServer/wwwroot/index.html b/MCP/McpServer/wwwroot/index.html index 95ee4d3..1dd72a0 100644 --- a/MCP/McpServer/wwwroot/index.html +++ b/MCP/McpServer/wwwroot/index.html @@ -398,6 +398,10 @@

Request Log

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. +
From f209aee0015761dbb9ad109740eeafb4eb2327f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20L=C3=B6sch?= Date: Thu, 23 Apr 2026 16:04:05 +0200 Subject: [PATCH 10/10] feat: enhance workspace loading with error handling and auto-open first item if no tabs are restored; add MCP consultation instructions --- .../mcp-consultation.instructions.md | 27 +++++++++++++++++++ Client/src/App.vue | 11 ++++++-- Client/src/stores/workspaceStore.js | 20 +++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 .github/instructions/mcp-consultation.instructions.md diff --git a/.github/instructions/mcp-consultation.instructions.md b/.github/instructions/mcp-consultation.instructions.md new file mode 100644 index 0000000..27a80c2 --- /dev/null +++ b/.github/instructions/mcp-consultation.instructions.md @@ -0,0 +1,27 @@ +--- +applyTo: "**" +--- + +# MCP Server Consultation + +The SolutionInventory MCP server runs at `http://localhost:5100` and is registered as **"Questionaire MCP"** in VS Code. + +## When to consult the MCP first + +Before answering questions about any of the following topics, **always call the relevant MCP tool first** to get live workspace data: + +| Topic | Tool to call | +|---|---| +| Categories, subcategories, entry IDs | `list_categories` | +| Questionnaire structure or IDs | `list_questionnaires` | +| Answers, responses, ratings for a category | `get_answers_for_category` | +| Tech Radar status or overrides | `get_tech_radar` | +| Consistency, completeness, warnings | `evaluate_responses` | +| JSON schema for workspace or questionnaire export | `get_json_schema` | + +## Rules + +- Do **not** guess or fabricate workspace data (project names, answers, categories, IDs). Always retrieve it from the MCP. +- If the MCP server is unreachable, say so explicitly rather than guessing. +- Prefer `list_categories` before any question involving category IDs or entry IDs, so the correct IDs are known. +- Prefer `get_json_schema` before generating or validating any workspace export JSON. diff --git a/Client/src/App.vue b/Client/src/App.vue index d9433cc..bd1af18 100644 --- a/Client/src/App.vue +++ b/Client/src/App.vue @@ -210,10 +210,17 @@ export default { break case 'open-workspace': { const result = await window.electronAPI.openWorkspaceFile() - if (!result || result.error) break + if (!result) break // canceled + if (result.error) { + console.error('[open-workspace] Failed to read file:', result.error) + break + } // Set workspaceDir to the file's directory so future saves go there await window.electronAPI.setWorkspaceDir(result.dirPath) - store.loadFromData(result.data) + const loaded = store.loadFromData(result.data) + if (!loaded) { + console.error('[open-workspace] File format not recognized:', result.filePath) + } break } case 'save-workspace': diff --git a/Client/src/stores/workspaceStore.js b/Client/src/stores/workspaceStore.js index 09c74b7..4cc46f1 100644 --- a/Client/src/stores/workspaceStore.js +++ b/Client/src/stores/workspaceStore.js @@ -203,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 }