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/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index f508621..e795c49 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -1,19 +1,26 @@ -name: CI – Dev Branch +name: Dev CI and 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 + # CI on push to Dev: test and build only (no deployment) # ------------------------------------------------------------------ - test: - name: E2E Tests + 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: @@ -59,15 +66,89 @@ jobs: 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 + # ------------------------------------------------------------------ - # 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 + if: github.event_name == 'workflow_dispatch' 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 +156,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ inputs.target_branch }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -103,15 +185,16 @@ 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 }}) + if: github.event_name == 'workflow_dispatch' needs: version runs-on: ${{ matrix.os }} @@ -179,10 +262,11 @@ 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 + if: github.event_name == 'workflow_dispatch' needs: [version, build-electron] runs-on: ubuntu-latest @@ -198,9 +282,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/**/* 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/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..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.
@@ -551,7 +558,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 +645,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 +657,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/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. 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: diff --git a/mcp-bridge.js b/mcp-bridge.js index 30ca840..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', @@ -148,13 +164,39 @@ 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'] + } + }, + { + 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'] + } } ]; - sendJsonRpc(createResponse(id, { tools })); - } catch (err) { - sendJsonRpc(createError(id, -32603, err.message)); - } + sendJsonRpc(createResponse(id, { tools })); } // ────────────────────────────────────────────────────────────────────────────── @@ -210,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); @@ -229,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); });