diff --git a/.github/workflows/deploy-mcp.yml b/.github/workflows/deploy-mcp.yml index 178f1f08..3b89520d 100644 --- a/.github/workflows/deploy-mcp.yml +++ b/.github/workflows/deploy-mcp.yml @@ -21,6 +21,22 @@ jobs: steps: - uses: actions/checkout@v5 + # The published customer documentation lives in its own PUBLIC repo and is + # baked into the image as the search_docs corpus (see the Dockerfile). No + # token is needed. Checking it out here rather than vendoring it keeps the + # docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A + # RUNNING SERVER; this workflow must be re-run. /health reports the SHA + # captured below so that staleness is checkable. + - name: Check out documentation bundle + uses: actions/checkout@v5 + with: + repository: autopilotmonitor/autopilotmonitor-docs + path: mcp-docs + + - name: Resolve docs commit + id: docs + run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Login to Azure (OIDC) uses: azure/login@v3 with: @@ -35,6 +51,7 @@ jobs: run: | docker build \ -f src/McpServer/autopilot-monitor-mcp/Dockerfile \ + --build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \ -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \ -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \ . diff --git a/.gitignore b/.gitignore index 6360a689..f5090ece 100644 --- a/.gitignore +++ b/.gitignore @@ -117,8 +117,11 @@ scripts/Deployment/ # Migration tooling (local only, vorerst) — cross-tenant storage/cert migration scripts scripts/Migration/ -# Infra (local only) -infra/ +# Infra: tracked. The Bicep templates carry no credentials — every secret is a +# @secure() parameter with an empty default, injected at deploy time, and image +# pull runs on managed identity. Keep it that way: never commit a resolved secret +# value or a *.parameters.json holding one. +infra/*.parameters.json # M2 — local raw session fixtures before anonymization (plan §4 M2) tests/fixtures/enrollment-sessions-local/ diff --git a/docs/index.md b/docs/index.md index 09b28c8f..a7b40f52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,10 @@ https://docs.autopilotmonitor.com (separate repository). * [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration. +# MCP Server + +* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy. + # Security & Identity * [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID. diff --git a/docs/log.md b/docs/log.md index 97a0b941..089ad739 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-07-21 + +* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `
` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load. + ## 2026-07-20 * **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule). diff --git a/docs/mcp/docs-corpus.md b/docs/mcp/docs-corpus.md new file mode 100644 index 00000000..bba63e05 --- /dev/null +++ b/docs/mcp/docs-corpus.md @@ -0,0 +1,159 @@ +--- +type: Concept +title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling +description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server. +resource: /src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts +tags: + - mcp + - search + - embeddings + - documentation + - build +timestamp: 2026-07-21T19:30:00+02:00 +--- + +# Purpose + +The MCP server answers two different kinds of question, and they need two +different corpora. + +* *"Why did this enrollment fail?"* → analysis rules, gather rules, IME log + patterns. That is `search_knowledge`, backed by `knowledge-base.ts`. +* *"How do I deploy the agent?" / "Where is my data stored?"* → the published + customer documentation. That is `search_docs`, backed by `docs-corpus.ts`. + +They are deliberately **separate indexes**. Merged into one, they would compete +for the same `topK` and dilute each other: a rules query would surface prose +pages, and a product question would surface rule definitions. + +# Schema + +## Corpus source + +The corpus is the `autopilotmonitor-docs` repository — a public OKF bundle +published at `docs.autopilotmonitor.com`. It is **not** vendored into this repo. +The deploy workflow checks it out into `mcp-docs/` of the Docker build context; +the Dockerfile copies it to `/app/docs` in both the build and the final stage +(byte-identical, or the boot-time corpus hash rejects the precomputed index). + +`SUMMARY.md` and `log.md` are skipped as navigation scaffolding. `index.md` is +kept — it is the documentation map and answers "what documentation exists" — but +gets no `url`, because the bundle deliberately keeps it out of `SUMMARY.md` and +GitBook therefore never renders it. + +## Chunking + +A whole page cannot be one document: `trust/security-faq.md` is 36 KB, while +MiniLM-L6-v2 truncates at ~256 tokens. An un-chunked page would silently embed +only its first paragraph. + +Chunks are cut at **h2/h3 headings**, which in this bundle are also the semantic +boundaries — `security-faq.md` carries 37 h3s, one per question. +`
Q` blocks (used by `troubleshooting/faq.md`) are +treated the same way, with the summary as the section title. Each chunk is +prefixed with a `Page Title › Section › Subsection` breadcrumb so a hit lifted +out of the middle of a page is self-describing to both the embedder and the +reader. + +Two deliberate exceptions to the ~1500-character budget: + +* **Markdown tables are never split.** A table broken across chunks leaves + `| --- |` rows with no header — unreadable in a result and meaningless to + embed. Tables carry real answers here (the role matrix, the settings + reference), so an over-long table is allowed to exceed the budget. +* **Unbroken lists split at line level.** The changelog pages are one bullet + list per release with no blank lines, so paragraph splitting cannot touch + them; left whole, everything past the model's window would be truncated away + and become unsearchable. + +Short sections are **kept**, not filtered. A one-line answer ("Is it free?" → +"Yes, the Community plan is free") is often the most valuable hit, and the +breadcrumb supplies the context the short body lacks. Only structural residue +(under 25 alphanumeric characters) is dropped. + +## CRLF is a correctness precondition + +The docs repo is authored on Windows and ships CRLF. In JavaScript `.` does not +match `\r`, so a pattern like `/^(#{2,3})\s+(.*)$/` matches **nothing** on a CRLF +file: `(.*)` stops before the `\r` and `$` never reaches end-of-string. + +This was observed during development, and the failure mode is what makes it +dangerous: no crash, no error — headings and frontmatter simply stop being +recognized and every page silently degrades to unlabelled paragraph splits. The +first working build produced 286 plausible-looking chunks that were almost all +wrong; after normalizing, `security-faq.md` went from 29 blind splits to 46 real +question sections. + +`normalizeNewlines()` runs before any parsing. Do not remove it, and do not +"fix" a future parsing bug here by adding `\r?` to one pattern. + +## Build-time embedding + +Like the rules corpus, docs are embedded once at Docker build time by +`precompute-embeddings.js [docsDir]`, into a third +`PrecomputedSection` (`docs`) of `search-index.json`. The container never embeds +a corpus at boot — only the incoming query. + +The `docs` section is **optional** in the file format, which is what makes a +local checkout without the docs repo work: `validatePrecomputedIndex` skips it +when the booting server loaded zero doc chunks. The asymmetry is intentional — +if the server *has* a docs corpus and the file does *not*, that is staleness and +is rejected like any other mismatch. + +Embeddings are rounded to 6 decimals on serialize. A float64 component +serializes to ~20 characters of JSON, so 384 dims cost ~7.8 KB per document; +rounding roughly halves the index file, which is `JSON.parse`d on every +scale-to-zero cold start. The induced L2 norm error (~1e-5 over 384 dims) sits +two orders of magnitude inside `UNIT_NORM_EPSILON`. + +## Registration + +`search_docs` is registered for **every role**, unlike the platform tools: this +is public documentation carrying no tenant data, and a tenant user asking "how do +I deploy the agent" has as much business here as a Global Admin. It is skipped +entirely when the corpus is empty, so a doc-less build advertises no broken tool. + +# Cross-repo coupling + +**Editing the documentation does not update a running MCP server.** The corpus is +baked into the image, so only re-running the *Deploy MCP Server* workflow picks +up doc changes. + +That coupling is made checkable rather than memorable: the build passes the docs +checkout SHA as `--build-arg DOCS_COMMIT`, and `/health` reports +`docs: { commit, chunks, sections }`. Comparing that commit against +`git rev-parse HEAD` in `autopilotmonitor-docs` answers "is the deployed +documentation stale?" directly. + +Automatic redeploy on a docs push was considered and rejected: it would need a +PAT secret in the docs repo and would turn every typo fix into a container +deployment. + +# Examples + +Retrieval measured against the real bundle (323 chunks, all-MiniLM-L6-v2): + +``` +"where is my data stored" → 0.566 troubleshooting/faq.md :: General › Where is my data stored? +"how do I deploy the agent with Intune" → 0.819 troubleshooting/faq.md :: Setup & Agent › How do I deploy the agent? +"which roles exist and what can they do" → 0.500 trust/security-faq.md :: Identity and Access › What roles exist? +"how do I set up Teams notifications" → 0.592 reference/settings.md :: Tenant › Notifications +"what firewall URLs do I need to allow" → 0.374 reference/network-endpoints.md :: (intro) +``` + +The 0.25 `minScore` default is tuned to that observed band — short keyword +queries on all-MiniLM score low, and a relevant-but-marginal hit lands around +0.34. + +# Citations + +* `/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts` — loader, frontmatter + parser, chunker +* `/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts` — `search_docs` + registration +* `/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts` — `docs` + section and its validation asymmetry +* `/src/McpServer/autopilot-monitor-mcp/Dockerfile` — `mcp-docs/` copy, + `DOCS_COMMIT` +* `/.github/workflows/deploy-mcp.yml` — docs checkout and SHA capture +* [MCP OAuth Flow](../mcp-oauth-flow.md) — the other MCP concept document diff --git a/infra/mcp-server.bicep b/infra/mcp-server.bicep new file mode 100644 index 00000000..c6b0220b --- /dev/null +++ b/infra/mcp-server.bicep @@ -0,0 +1,260 @@ +// Autopilot-Monitor MCP Server — Azure Container App Infrastructure +// +// Reconciled against the live deployment on 2026-07-21 (rg-autopilotmonitor-prd-gwc, +// Germany West Central). The template had drifted behind two changes made directly +// in Azure: the EU cutover (registry/API renamed) and the ACR hardening (admin user +// disabled, image pull moved to the app's system-assigned identity). +// +// !! BEFORE RE-DEPLOYING THIS TEMPLATE, READ THIS !! +// The live app carries an ingress custom-domain binding for mcp.autopilotmonitor.com +// (SNI-enabled, with a managed certificate). Custom domains are NOT modelled below, +// because the certificate is bound out-of-band. A full template deployment would +// therefore REMOVE that binding and take the public MCP endpoint offline. +// For routine changes (CPU/memory, env vars, image) prefer the surgical CLI form: +// az containerapp update -n autopilotmonitor-mcp -g rg-autopilotmonitor-prd-gwc --cpu 0.5 --memory 1.0Gi +// Treat this file as the documented desired state and as a rebuild-from-scratch +// recipe — not as a routinely-applied deployment. +// +// On a FROM-SCRATCH deploy the AcrPull assignment below is created only after the +// Container App exists (it needs the app's principalId), so the very first revision +// can fail to pull its image. Re-run the deployment, or restart the revision, once +// the role assignment has propagated. This does not affect updates to a live app. +// +// Deploy (two-stage; required to set the OAuth public URL safely): +// 1. First deploy without mcpPublicUrl: +// az deployment group create \ +// --resource-group \ +// --template-file infra/mcp-server.bicep \ +// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \ +// entraClientSecret= +// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp...azurecontainerapps.io). +// +// 2. Re-deploy with that URL pinned, so the OAuth proxy never trusts caller- +// supplied X-Forwarded-Host / Host headers when building issuer / WWW- +// Authenticate / redirect_uri metadata: +// --parameters mcpPublicUrl= +// +// When mcpPublicUrl is set, the MCP server uses it verbatim and ignores any +// forwarded-headers from the request. Without it, the server logs a startup +// warning and falls back to header-derived URLs (acceptable for local dev, +// not for production). + +@description('Azure region for all resources') +param location string = resourceGroup().location + +@description('Name of the Container App') +param containerAppName string = 'autopilotmonitor-mcp' + +@description('Name of the Container App Environment') +param environmentName string = 'autopilotmonitor-env' + +@description('Name of the Log Analytics Workspace (dedicated for Container Apps)') +param logAnalyticsName string = 'autopilotmonitor-container-logs' + +@description('Name of the Azure Container Registry') +param acrName string = 'autopilotmonitoreuacr' + +@description('Backend API URL') +param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net' + +@description('Container image tag') +param imageTag string = 'latest' + +@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.') +param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d' + +@secure() +@description('Entra ID client secret for OAuth token exchange') +param entraClientSecret string = '' + +@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).') +param mcpAllowedRedirectHosts string = '' + +@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).') +param mcpPublicUrl string = '' + +@secure() +// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode +// >=). `az bicep build --stdout` writes through the Windows console codec and +// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine. +@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))') +param oAuthStateSigningKey string = '' + +@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)') +param toolLoggingEnabled bool = false + +// --- Azure Container Registry --- + +// Admin user is DISABLED: a registry-wide username/password pair is a static, +// non-rotating credential that would also have to live in a Container App secret. +// The app pulls with its system-assigned identity + AcrPull instead (see below). +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: acrName + location: location + sku: { + name: 'Basic' + } + properties: { + adminUserEnabled: false + } +} + +// AcrPull for the Container App's system-assigned identity — this is what replaces +// the admin credentials. Without it the app cannot pull its own image. +// +// NOTE: this assignment exists on the live registry but could not be enumerated +// during reconciliation (the CLI session lacks Microsoft.Authorization read), so it +// is reproduced here from the documented pattern rather than read back from Azure. +// Verify with: +// az role assignment list --scope --assignee +var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' + +resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: acr + name: guid(acr.id, containerAppName, acrPullRoleId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId) + principalId: containerApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// --- Log Analytics Workspace (required by Container App Environment) --- + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +// --- Container App Environment --- + +resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: environmentName + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + } +} + +// --- Container App --- + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName + location: location + // System-assigned identity: the credential the app uses to pull from the ACR + // (see the AcrPull assignment above). Replaces the former admin-user secret. + identity: { + type: 'SystemAssigned' + } + properties: { + managedEnvironmentId: environment.id + configuration: { + ingress: { + external: true + targetPort: 8080 + transport: 'auto' + allowInsecure: false + } + // 'system' = pull with the app's system-assigned identity. No username, no + // password secret — nothing to rotate and nothing to leak from the app config. + registries: [ + { + server: acr.properties.loginServer + identity: 'system' + } + ] + secrets: [ + { + name: 'entra-client-secret' + value: entraClientSecret + } + { + name: 'oauth-state-signing-key' + value: oAuthStateSigningKey + } + ] + activeRevisionsMode: 'Single' + } + template: { + containers: [ + { + name: 'mcp-server' + image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}' + // 0.5 vCPU is deliberate, not a default. The corpus embeddings are + // precomputed at image build time, so steady-state search is cheap — + // but a scale-to-zero cold start still loads the ~23 MB ONNX embedding + // model to embed the incoming QUERY, and that is seconds of pure CPU on + // the critical path of the first search. Doubling the vCPU roughly + // halves it. Container Apps only accepts fixed CPU/memory pairs, so the + // memory moves with it. Do not "optimize" this back to 0.25. + resources: { + cpu: json('0.5') + memory: '1.0Gi' + } + env: [ + { name: 'AUTOPILOT_API_URL', value: apiUrl } + { name: 'PORT', value: '8080' } + { name: 'AUTOPILOT_ENTRA_CLIENT_ID', value: entraClientId } + { name: 'AUTOPILOT_ENTRA_CLIENT_SECRET', secretRef: 'entra-client-secret' } + { name: 'MCP_ALLOWED_REDIRECT_HOSTS', value: mcpAllowedRedirectHosts } + { name: 'MCP_PUBLIC_URL', value: mcpPublicUrl } + { name: 'OAuthStateSigningKey', secretRef: 'oauth-state-signing-key' } + { name: 'MCP_TOOL_LOGGING', value: toolLoggingEnabled ? 'true' : 'false' } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health' + port: 8080 + } + periodSeconds: 30 + initialDelaySeconds: 10 + } + { + type: 'Readiness' + httpGet: { + path: '/health' + port: 8080 + } + periodSeconds: 10 + initialDelaySeconds: 5 + } + ] + } + ] + scale: { + minReplicas: 0 + maxReplicas: 1 + rules: [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: '10' + } + } + } + ] + } + } + } +} + +// --- Outputs --- + +output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}' +output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp' +output acrLoginServer string = acr.properties.loginServer diff --git a/src/McpServer/autopilot-monitor-mcp/Dockerfile b/src/McpServer/autopilot-monitor-mcp/Dockerfile index 3456d032..512870e4 100644 --- a/src/McpServer/autopilot-monitor-mcp/Dockerfile +++ b/src/McpServer/autopilot-monitor-mcp/Dockerfile @@ -18,6 +18,22 @@ COPY rules/analyze/ ./rules/analyze/ COPY rules/gather/ ./rules/gather/ COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/ +# Published customer documentation (autopilotmonitor-docs) — a SEPARATE public +# repo that the deploy workflow checks out into ./mcp-docs of the build context. +# Same byte-identical requirement as the rules above. +# +# This COPY is deliberately strict: if the workflow's docs checkout is ever +# removed, the build must fail loudly rather than quietly ship an image whose +# search_docs tool simply does not exist. For a local `docker build`, clone the +# bundle first: +# git clone --depth 1 https://github.com/autopilotmonitor/autopilotmonitor-docs mcp-docs +COPY mcp-docs/ ./docs/ + +# Commit SHA of that docs checkout. Surfaced on /health so a running server can be +# diffed against the docs repo HEAD — the bundle changes independently of this +# repo, so a stale deploy is otherwise invisible. +ARG DOCS_COMMIT=unknown + # Pre-bake the ~23 MB embedding model AND precompute the search index at image # build time. Embedding the static corpus at boot cost 35-55s of ONNX inference # on the 0.25 vCPU container (measured); hydrating precomputed vectors is @@ -35,7 +51,7 @@ RUN mkdir -p "$HF_CACHE_DIR" /app/search-index && \ if [ "$PREBAKE_MODEL" = "1" ]; then \ ok=0; \ for i in 1 2 3 4 5; do \ - node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json && { ok=1; break; }; \ + node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json ./docs && { ok=1; break; }; \ echo "embedding precompute attempt $i/5 failed; retrying in 15s"; \ sleep 15; \ done && \ @@ -66,8 +82,16 @@ COPY rules/analyze/ ./rules/analyze/ COPY rules/gather/ ./rules/gather/ COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/ +# Customer documentation corpus — copied from the build stage rather than the +# context so it is provably the same bytes the embeddings were computed over. +COPY --from=build /app/docs/ ./docs/ + +ARG DOCS_COMMIT=unknown +ENV DOCS_COMMIT=${DOCS_COMMIT} + ENV PORT=8080 ENV RULES_DIR=/app/rules +ENV DOCS_DIR=/app/docs ENV SEARCH_INDEX_PATH=/app/search-index/search-index.json EXPOSE 8080 diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/docs-corpus.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/docs-corpus.test.ts new file mode 100644 index 00000000..9dc33b9a --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/docs-corpus.test.ts @@ -0,0 +1,210 @@ +/** + * Unit tests for the customer-documentation corpus loader. + * + * The bundle is authored on Windows and ships CRLF, which is the single most + * dangerous property of this input: in JavaScript `.` does not match `\r`, so a + * pattern like /^(#{2,3})\s+(.*)$/ silently matches NOTHING on a CRLF file. The + * failure is not a crash — headings and frontmatter just stop being recognized + * and every page degrades to unlabelled paragraph splits. Several tests below + * therefore run the CRLF form on purpose. + */ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js'; + +async function withBundle( + files: Record, + fn: (root: string) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'docs-corpus-')); + try { + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + await mkdir(join(full, '..'), { recursive: true }); + await writeFile(full, content, 'utf-8'); + } + return await fn(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +describe('parseFrontmatter', () => { + it('reads scalars, folded blocks and inline sequences', () => { + const { frontmatter, body } = parseFrontmatter( + ['---', 'type: Concept', 'tags: [security, gdpr]', 'description: >-', ' First line', ' second line', '---', '', '# Title', 'Body.'].join('\n'), + ); + expect(frontmatter.type).toBe('Concept'); + expect(frontmatter.tags).toEqual(['security', 'gdpr']); + expect(frontmatter.description).toBe('First line second line'); + expect(body).toContain('# Title'); + expect(body).not.toContain('type: Concept'); + }); + + it('reads block sequences', () => { + const { frontmatter } = parseFrontmatter(['---', 'type: Guide', 'tags:', ' - agent', ' - intune', '---', 'x'].join('\n')); + expect(frontmatter.tags).toEqual(['agent', 'intune']); + }); + + it('parses CRLF frontmatter identically to LF', () => { + const lf = ['---', 'type: Concept', 'tags: [a, b]', '---', '', '# T', 'x'].join('\n'); + expect(parseFrontmatter(lf.replace(/\n/g, '\r\n'))).toEqual(parseFrontmatter(lf)); + }); + + it('returns the whole input as body when there is no frontmatter', () => { + const { frontmatter, body } = parseFrontmatter('# Just a title\n\nText.'); + expect(frontmatter).toEqual({ type: undefined, title: undefined, description: undefined, tags: undefined }); + expect(body).toBe('# Just a title\n\nText.'); + }); +}); + +describe('docUrl', () => { + it('maps a page path to its published URL', () => { + expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq'); + }); + + it('collapses README.md to its folder', () => { + expect(docUrl('rules/analyze-rules/README.md')).toBe('https://docs.autopilotmonitor.com/rules/analyze-rules'); + expect(docUrl('README.md')).toBe('https://docs.autopilotmonitor.com'); + }); + + it('has no URL for index.md — GitBook never publishes the OKF catalog', () => { + expect(docUrl('index.md')).toBeUndefined(); + }); +}); + +describe('slugify', () => { + it('strips markdown and punctuation', () => { + expect(slugify('Is the device\'s **IP address** stored?')).toBe('is-the-device-s-ip-address-stored'); + }); +}); + +describe('loadDocsCorpus', () => { + const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`; + + it('splits on h2/h3 headings and carries a breadcrumb', async () => { + const chunks = await withBundle( + { + 'concepts/roles.md': page( + 'type: Concept\ntags: [roles]', + ['# Roles & Permissions', '', 'Intro prose that is long enough to survive the minimum-length filter for chunks.', '', '## Tenant roles', '', 'A tenant admin can do everything, including managing team members and enabling admin mode.', '', '### Operator', '', 'An operator monitors day to day and cannot perform destructive operations at all.'].join('\n'), + ), + }, + (root) => loadDocsCorpus(root), + ); + + const headings = chunks.map((c) => c.metadata.heading); + expect(headings).toContain('Tenant roles'); + expect(headings).toContain('Tenant roles › Operator'); + // The page title is prepended to every chunk so a mid-page hit is self-describing. + expect(chunks.every((c) => c.text.startsWith('Roles & Permissions'))).toBe(true); + expect(chunks[0].metadata.section).toBe('concepts'); + expect(chunks[0].metadata.tags).toEqual(['roles']); + }); + + it('produces the SAME chunks for CRLF input as for LF', async () => { + const body = ['# Guide', '', 'Intro paragraph long enough to be kept as its own indexed chunk here.', '', '## Step one', '', 'Do the first thing, which needs a sentence long enough to pass the minimum.', '', '## Step two', '', 'Then do the second thing, also written out at a reasonable length for this.'].join('\n'); + const src = page('type: Guide\ntags: [setup]', body); + + const lf = await withBundle({ 'getting-started/g.md': src }, (r) => loadDocsCorpus(r)); + const crlf = await withBundle({ 'getting-started/g.md': src.replace(/\n/g, '\r\n') }, (r) => loadDocsCorpus(r)); + + expect(crlf).toEqual(lf); + expect(lf.map((c) => c.metadata.heading)).toEqual([null, 'Step one', 'Step two']); + expect(lf.some((c) => c.text.includes('\r'))).toBe(false); + }); + + it('treats
/ blocks as sections', async () => { + const chunks = await withBundle( + { + 'troubleshooting/faq.md': page( + 'type: FAQ\ntags: [faq]', + ['# FAQ', '', '## General', '', '
', '', 'Is Autopilot Monitor free?', '', 'Yes, the Community plan is free and stays free for everyone who wants to use it.', '', '
'].join('\n'), + ), + }, + (root) => loadDocsCorpus(root), + ); + expect(chunks.map((c) => c.metadata.heading)).toContain('General › Is Autopilot Monitor free?'); + }); + + it('never emits a heading from inside a fenced code block', async () => { + const chunks = await withBundle( + { + 'reference/cli.md': page( + 'type: Reference\ntags: [cli]', + ['# CLI', '', '## Usage', '', '```powershell', '# This is a shell comment, not a heading', '## Neither is this', '```', '', 'Explanatory prose after the block so the section is comfortably long enough.'].join('\n'), + ), + }, + (root) => loadDocsCorpus(root), + ); + expect(chunks.map((c) => c.metadata.heading)).toEqual(['Usage']); + }); + + it('skips SUMMARY.md and log.md but keeps index.md', async () => { + const chunks = await withBundle( + { + 'SUMMARY.md': '# Table of contents\n\n* [A](a.md)\n', + 'log.md': '# Log\n\nSome change record entry that is long enough to be a chunk on its own.\n', + 'index.md': page('okf_version: "0.1"', '# Bundle\n\nThe documentation map listing every page available in this bundle here.'), + }, + (root) => loadDocsCorpus(root), + ); + const paths = [...new Set(chunks.map((c) => c.metadata.path))]; + expect(paths).toEqual(['index.md']); + // index.md is indexed for its content but is not a published page. + expect(chunks[0].metadata.url).toBeNull(); + }); + + it('splits an oversized unbroken list so its tail is not truncated away', async () => { + const items = Array.from({ length: 60 }, (_, i) => `* Release note number ${i} describing a change that shipped in this release.`); + const chunks = await withBundle( + { 'changelog/platform.md': page('type: Changelog\ntags: [changelog]', ['# Platform Changelog', '', '## July 2026', '', ...items].join('\n')) }, + (root) => loadDocsCorpus(root), + ); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((c) => c.text.length <= 1700)).toBe(true); + // The final item must still be reachable — that is the whole point of splitting. + expect(chunks.some((c) => c.text.includes('number 59'))).toBe(true); + }); + + it('keeps a markdown table intact rather than splitting mid-table', async () => { + const rows = Array.from({ length: 40 }, (_, i) => `| Setting ${i} | A reasonably wordy description of what this particular setting controls. |`); + const chunks = await withBundle( + { 'reference/settings.md': page('type: Reference\ntags: [settings]', ['# Settings', '', '## All settings', '', '| Name | Description |', '| --- | --- |', ...rows].join('\n')) }, + (root) => loadDocsCorpus(root), + ); + const withTable = chunks.filter((c) => c.text.includes('| Setting ')); + expect(withTable).toHaveLength(1); + expect(withTable[0].text).toContain('| Setting 39 |'); + }); + + it('assigns unique, stable ids across repeated loads', async () => { + const files = { + 'a/one.md': page('type: Guide\ntags: [x]', '# One\n\n## Same\n\nFirst body text that is long enough to be indexed as a chunk.\n\n## Same\n\nSecond body text that is also long enough to be indexed here.'), + }; + const first = await withBundle(files, (r) => loadDocsCorpus(r)); + const second = await withBundle(files, (r) => loadDocsCorpus(r)); + expect(new Set(first.map((c) => c.id)).size).toBe(first.length); + expect(first.map((c) => c.id)).toEqual(second.map((c) => c.id)); + }); + + it('returns an empty corpus for a missing directory instead of throwing', async () => { + await expect(loadDocsCorpus(join(tmpdir(), 'definitely-not-here-9d2f'))).resolves.toEqual([]); + }); +}); + +describe('docSections', () => { + it('lists distinct top-level areas, sorted', async () => { + const chunks = await withBundle( + { + 'trust/a.md': '# A\n\nSome prose that is definitely long enough to become an indexed chunk.', + 'concepts/b.md': '# B\n\nMore prose that is definitely long enough to become an indexed chunk.', + 'root.md': '# R\n\nRoot level prose that is long enough to become an indexed chunk too.', + }, + (root) => loadDocsCorpus(root), + ); + expect(docSections(chunks)).toEqual(['concepts', 'general', 'trust']); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/precomputed-index.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/precomputed-index.test.ts index 33d1baa6..05f31e4f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/precomputed-index.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/precomputed-index.test.ts @@ -14,8 +14,19 @@ const MODEL = 'Xenova/all-MiniLM-L6-v2'; const doc = (id: string, text: string, metadata: Record = {}): SearchDocument => ({ id, text, metadata }); +/** + * Fake embeddings still have to satisfy the real contract: indexPrecomputed() + * asserts L2-unit norm, because cosineSimilarity is a bare dot product that would + * otherwise return silently wrong scores. Normalize here rather than hand-writing + * unit vectors, so a test can pass any direction it finds readable. + */ +const unit = (v: number[]): number[] => { + const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)); + return v.map((x) => x / norm); +}; + const withEmbedding = (d: SearchDocument, embedding: number[] = [0.1, 0.2, 0.3]): PrecomputedDocument => - ({ ...d, embedding }); + ({ ...d, embedding: unit(embedding) }); const KB_DOCS = [doc('rule-1', 'TPM not ready', { type: 'analyze-rule' }), doc('rule-2', 'ESP timeout', { type: 'analyze-rule' })]; const ET_DOCS = [doc('et-1', 'bitlocker status', { eventType: 'bitlocker_status' })]; @@ -106,7 +117,66 @@ describe('validatePrecomputedIndex', () => { }); }); +describe('docs section (optional third corpus)', () => { + const DOC_CHUNKS = [doc('docs:trust/security-faq.md#intro', 'Where is my data stored', { type: 'doc', section: 'trust' })]; + + const withDocs = (): PrecomputedIndexFile => ({ + ...validFile(), + docs: { docsHash: hashDocs(DOC_CHUNKS), entries: DOC_CHUNKS.map((d) => withEmbedding(d)) }, + }); + + it('returns the docs entries when the corpus matches', () => { + const result = validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS); + expect(result).toMatchObject({ ok: true }); + expect(result.ok && result.docs).toHaveLength(1); + }); + + it('rejects the whole file when the docs corpus changed since the build', () => { + const stale = [doc('docs:trust/security-faq.md#intro', 'DIFFERENT TEXT', { type: 'doc', section: 'trust' })]; + expect(validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, stale)).toMatchObject({ + ok: false, reason: expect.stringContaining('docs'), + }); + }); + + it('rejects when the server has docs but the image shipped none — that is staleness, not absence', () => { + expect(validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS)).toMatchObject({ + ok: false, reason: expect.stringContaining('docs'), + }); + }); + + it('accepts a file WITHOUT a docs section when the server has no docs corpus', () => { + // A local checkout has no docs repo. That must not throw away the rules and + // event-type vectors too — recomputing those is the expensive half. + const result = validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, []); + expect(result).toMatchObject({ ok: true }); + expect(result.ok && result.docs).toEqual([]); + }); +}); + describe('VectorSearchProvider serialize/hydrate roundtrip', () => { + it('rounds embeddings on serialize but stays well inside the unit-norm tolerance', () => { + const p = new VectorSearchProvider(); + const raw = unit([0.123456789012345, -0.987654321098765, 0.5555555555555]); + p.indexPrecomputed([{ ...doc('d1', 'text'), embedding: raw }]); + + const [out] = p.serialize(); + // Rounded to 6 decimals — the point of the exercise (halves the index JSON). + expect(out.embedding.every((v) => v === Number(v.toFixed(6)))).toBe(true); + expect(out.embedding).not.toEqual(raw); + + const norm = Math.sqrt(out.embedding.reduce((s, x) => s + x * x, 0)); + expect(Math.abs(norm - 1)).toBeLessThan(1e-5); + // Re-hydrating the rounded vectors must not trip the assertion. + expect(() => new VectorSearchProvider().indexPrecomputed(p.serialize())).not.toThrow(); + }); + + it('rejects a non-unit embedding on hydrate rather than skewing every score silently', () => { + const p = new VectorSearchProvider(); + expect(() => p.indexPrecomputed([{ ...doc('d1', 'text'), embedding: [0.1, 0.2, 0.3] }])) + .toThrow(/not L2-unit-normalized/); + }); + + it('indexPrecomputed restores exactly what serialize exported, without the model', () => { const source = new VectorSearchProvider(); source.indexPrecomputed(KB_DOCS.map((d, i) => withEmbedding(d, [i + 0.5, i + 1.5]))); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index 8bb18823..b52337c1 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -20,16 +20,62 @@ import { describe, it, expect } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerTools } from '../tools.js'; +import type { SearchProvider } from '../search-provider.js'; function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] { const server = new McpServer({ name: 'test', version: '0.0.0' }); // knowledgeBase / eventTypeIndex are optional — pass undefined so this stays a // pure unit test with no search-provider or backend dependency. - registerTools(server, undefined, undefined, ga, strictGa, delegated); + registerTools(server, undefined, undefined, undefined, [], ga, strictGa, delegated); const internal = server as unknown as { _registeredTools: Record }; return Object.keys(internal._registeredTools); } +/** Minimal stand-in for an indexed docs corpus — registration only checks `size`. */ +function stubDocsIndex(size = 3): SearchProvider { + return { + name: 'stub', + semanticCapable: true, + size, + index: async () => {}, + search: async () => [], + }; +} + +function namesWithDocs(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerTools(server, undefined, undefined, stubDocsIndex(), ['concepts', 'trust'], ga, strictGa, delegated); + const internal = server as unknown as { _registeredTools: Record }; + return Object.keys(internal._registeredTools); +} + +describe('search_docs registration', () => { + it('is NOT registered when no documentation corpus was baked into the image', () => { + expect(registeredToolNames(true)).not.toContain('search_docs'); + }); + + it('is registered when a corpus is present — for EVERY role, including a plain tenant user', () => { + // Published product documentation carries no tenant data, so unlike the + // platform tools it is deliberately ungated. A tenant user asking "how do I + // deploy the agent" has exactly as much business here as a Global Admin. + expect(namesWithDocs(true, true)).toContain('search_docs'); + expect(namesWithDocs(false, false)).toContain('search_docs'); + expect(namesWithDocs(false, false, true)).toContain('search_docs'); + }); + + it('is skipped for an empty corpus, so a doc-less build advertises no broken tool', () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerTools(server, undefined, undefined, stubDocsIndex(0), [], true, true, false); + const internal = server as unknown as { _registeredTools: Record }; + expect(Object.keys(internal._registeredTools)).not.toContain('search_docs'); + }); + + it('keeps the catalog alphabetically sorted once it joins', () => { + const names = namesWithDocs(true); + expect(names).toEqual([...names].sort()); + }); +}); + describe('tool catalog ordering', () => { it('lists tools in alphabetical order for a Global Admin', () => { const names = registeredToolNames(true); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts index 4fde39d7..ac0f692d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts @@ -27,7 +27,7 @@ const MANAGED = 'aaaa-1111-managed'; /** Build a server for the given caller shape and return a tool's raw handler by name. */ function handlerFor(name: string, opts: { ga?: boolean; strictGa?: boolean; delegated?: boolean }): ToolHandler { const server = new McpServer({ name: 'test', version: '0.0.0' }); - registerTools(server, undefined, undefined, opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false); + registerTools(server, undefined, undefined, undefined, [], opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false); const registry = (server as unknown as { _registeredTools: Record })._registeredTools; const tool = registry[name]; if (!tool) throw new Error(`tool ${name} not registered for this caller shape`); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-raw-adversarial.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-raw-adversarial.test.ts index ba9d3e86..f062aecb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-raw-adversarial.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-raw-adversarial.test.ts @@ -29,7 +29,7 @@ type ToolHandler = (args: Record, extra: unknown) => Promise<{ /** Build a strict-GA server and return a tool's raw handler by name. */ function gaHandler(name: string): ToolHandler { const server = new McpServer({ name: 'test', version: '0.0.0' }); - registerTools(server, undefined, undefined, /*ga*/ true, /*strictGa*/ true, /*delegated*/ false); + registerTools(server, undefined, undefined, undefined, [], /*ga*/ true, /*strictGa*/ true, /*delegated*/ false); const registry = (server as unknown as { _registeredTools: Record })._registeredTools; const tool = registry[name]; if (!tool) throw new Error(`tool ${name} not registered for strict-GA caller`); diff --git a/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts b/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts new file mode 100644 index 00000000..9c5d40b7 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts @@ -0,0 +1,461 @@ +/** + * Loads the published customer documentation bundle (autopilotmonitor-docs) and + * chunks it into SearchDocuments for semantic search via the `search_docs` tool. + * + * This is the product-knowledge corpus — "how do I deploy the agent", "where is + * my data stored", "which roles exist". It is deliberately SEPARATE from the + * rules corpus in knowledge-base.ts: rules answer "why did this session fail", + * docs answer "how does the product work". Indexed together they would compete + * for the same topK and dilute each other. + * + * Like the rules corpus, docs are baked into the image and embedded at Docker + * build time (see precompute-embeddings.ts) — the 0.25-0.5 vCPU container never + * embeds a corpus at boot. + * + * Chunking matters more here than for rules: a rule is already a short, self- + * contained record, whereas a doc page runs to 36 KB (trust/security-faq.md). + * MiniLM-L6-v2 truncates at ~256 tokens, so a whole-page embedding would silently + * represent only the first paragraph. We therefore split on heading boundaries, + * which in this bundle are also the semantic boundaries (one h3 = one question). + */ + +import { readdir, readFile } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import type { SearchDocument } from './search-provider.js'; + +/** Published base URL of the bundle — used to build a citable link per chunk. */ +const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com'; + +/** + * Upper bound on chunk text. MiniLM-L6-v2's 256-token window is roughly 1000-1200 + * characters of English prose; going much beyond that means the tail of the chunk + * contributes nothing to the embedding. We allow a little slack over that so a + * coherent section (or an indivisible table) is not split for the sake of a few + * characters — the overflow is only ever the part that gets truncated. + */ +const MAX_CHUNK_CHARS = 1500; + +/** + * A trailing fragment shorter than this is folded back into the preceding chunk + * when splitting an oversized section, so a split never leaves a stranded line. + */ +const MIN_CHUNK_CHARS = 120; + +/** + * Minimum body length for a chunk to be indexed at all, EXCLUDING the breadcrumb + * header. Set low on purpose: a short section is very often the most valuable + * kind of hit ("Is Autopilot Monitor free?" → "Yes — the Community plan is free + * and stays free"), and the breadcrumb already supplies the context that a short + * body lacks. This filter only exists to drop structural residue — a stray rule + * line, an empty section left behind by stripped GitBook markers. + */ +const MIN_BODY_CHARS = 25; + +/** + * Structural/meta files that are not documentation content. + * `SUMMARY.md` is the GitBook table of contents and `log.md` is the OKF change + * log — both are navigation scaffolding whose text would match queries about + * every topic at once. `index.md` (the OKF bundle catalog) is deliberately KEPT: + * it is the documentation map and answers "what documentation exists". + */ +const SKIP_FILES = new Set(['SUMMARY.md', 'log.md']); + +const SKIP_DIRS = new Set(['.git', '.github', '.gitbook', 'node_modules']); + +// ── Frontmatter ────────────────────────────────────────────── + +/** + * Normalize CRLF/CR to LF before ANY parsing. + * + * Not cosmetic — a correctness precondition. The docs repo is authored on + * Windows and ships CRLF, and in JavaScript `.` does not match `\r`. A pattern + * like /^(#{2,3})\s+(.*)$/ therefore fails on every single heading of a CRLF + * file: `(.*)` stops before the `\r` and `$` never reaches end-of-string. The + * observable symptom is silent, not a crash — headings and frontmatter keys + * simply stop being recognized and the page degrades to unlabelled paragraph + * splits. Normalizing once here is the root fix; the alternative is remembering + * `\r?` in every pattern in this file forever. + */ +function normalizeNewlines(text: string): string { + return text.replace(/\r\n?/g, '\n'); +} + +export interface DocFrontmatter { + type?: string; + title?: string; + description?: string; + tags?: string[]; +} + +/** + * Minimal YAML frontmatter parser covering exactly the forms this bundle uses: + * plain scalars, folded blocks (`description: >-`), inline sequences + * (`tags: [a, b]`) and block sequences (`- a`). A full YAML parser would be a new + * runtime dependency in a hardened, dependency-minimal container for the sake of + * four flat keys. + * + * Anything it cannot parse is skipped rather than thrown — a malformed header + * must not cost us the page body, which is the part being searched. + */ +export function parseFrontmatter(input: string): { frontmatter: DocFrontmatter; body: string } { + const raw = normalizeNewlines(input); + if (!raw.startsWith('---')) return { frontmatter: {}, body: raw }; + const end = raw.indexOf('\n---', 3); + if (end === -1) return { frontmatter: {}, body: raw }; + + const header = raw.slice(raw.indexOf('\n') + 1, end); + const body = raw.slice(raw.indexOf('\n', end + 1) + 1); + const fm: Record = {}; + + const lines = header.split('\n'); + for (let i = 0; i < lines.length; i++) { + const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]); + if (!match) continue; + const key = match[1]; + let value = match[2].trim(); + + // Folded / literal block scalar: consume the following indented lines. + if (value === '>-' || value === '>' || value === '|' || value === '|-') { + const parts: string[] = []; + while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) { + parts.push(lines[++i].trim()); + } + fm[key] = parts.join(' '); + continue; + } + + // Block sequence: `tags:` followed by `- item` lines. + if (value === '') { + const items: string[] = []; + while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) { + items.push(lines[++i].replace(/^\s*-\s+/, '').trim().replace(/^["']|["']$/g, '')); + } + if (items.length > 0) fm[key] = items; + continue; + } + + // Inline sequence: `[a, b, c]`. + if (value.startsWith('[') && value.endsWith(']')) { + fm[key] = value + .slice(1, -1) + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + continue; + } + + fm[key] = value.replace(/^["']|["']$/g, ''); + } + + const asString = (v: string | string[] | undefined): string | undefined => + typeof v === 'string' ? v : undefined; + const asArray = (v: string | string[] | undefined): string[] | undefined => + Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : undefined; + + return { + frontmatter: { + type: asString(fm.type), + title: asString(fm.title), + description: asString(fm.description), + tags: asArray(fm.tags), + }, + body, + }; +} + +// ── Markdown normalization ─────────────────────────────────── + +/** + * Strip GitBook-specific block syntax while keeping the prose inside it. + * `{% hint style="info" %}` wrappers carry no meaning for retrieval but do add + * tokens that dilute the embedding; the sentence inside them frequently IS the + * answer, so the content is preserved and only the markers removed. + */ +function stripGitbookSyntax(text: string): string { + return text + .replace(/^\s*\{%\s*(end)?(hint|tabs|tab|content-ref|embed|code)[^%]*%\}\s*$/gim, '') + .replace(/^\s*\{%\s*end\w+\s*%\}\s*$/gim, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** URL/anchor-safe slug from heading text. */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[`*_[\]()]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +/** + * Public GitBook URL for a bundle-relative path, or undefined when the file is + * not published as a page. + * + * `index.md` is an OKF reserved file that the bundle deliberately keeps out of + * SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a + * 404. Its content is still indexed (it is the documentation map); it just has no + * citable URL. + */ +export function docUrl(relPath: string): string | undefined { + const posix = relPath.split(sep).join('/'); + if (posix === 'index.md') return undefined; + const withoutExt = posix.replace(/\.md$/, ''); + const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, ''); + return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL; +} + +// ── Sectioning ─────────────────────────────────────────────── + +interface Section { + /** Heading trail below the page title, e.g. ['Data Processing', 'What data…']. */ + breadcrumb: string[]; + /** Anchor of the deepest markdown heading, if this section came from one. */ + anchor?: string; + content: string; +} + +/** + * Split a page body into heading-delimited sections. + * + * Handles both structures the bundle uses: markdown h2/h3 headings (the common + * case — trust/security-faq.md carries 37 h3s, one per question) and + * `
Q…
` blocks (troubleshooting/faq.md), + * where the summary is the question and therefore the natural section title. + */ +function splitIntoSections(body: string): Section[] { + const sections: Section[] = []; + const lines = body.split('\n'); + + let h2: string | null = null; + let h3: string | null = null; + let detailsTitle: string | null = null; + let buffer: string[] = []; + let inFence = false; + + const flush = (anchorSource: string | null) => { + const content = buffer.join('\n').trim(); + buffer = []; + if (!content) return; + const breadcrumb = [h2, detailsTitle ?? h3].filter((s): s is string => Boolean(s)); + sections.push({ + breadcrumb, + anchor: anchorSource ? slugify(anchorSource) : undefined, + content, + }); + }; + + for (const line of lines) { + // Never interpret markdown inside a fenced code block as structure. + if (/^\s*```/.test(line)) { + inFence = !inFence; + buffer.push(line); + continue; + } + if (inFence) { + buffer.push(line); + continue; + } + + const summary = /^\s*(.*?)<\/summary>\s*$/i.exec(line); + if (summary) { + flush(detailsTitle ?? h3 ?? h2); + detailsTitle = summary[1].replace(/<[^>]+>/g, '').trim(); + continue; + } + if (/^\s*<\/details>\s*$/i.test(line)) { + flush(null); + detailsTitle = null; + continue; + } + if (/^\s*
\s*$/i.test(line)) continue; + + const heading = /^(#{2,3})\s+(.*)$/.exec(line); + if (heading) { + flush(detailsTitle ?? h3 ?? h2); + detailsTitle = null; + const text = heading[2].replace(/[*_`]/g, '').trim(); + if (heading[1] === '##') { + h2 = text; + h3 = null; + } else { + h3 = text; + } + continue; + } + + buffer.push(line); + } + flush(detailsTitle ?? h3 ?? h2); + + return sections; +} + +/** + * Split an oversized section at paragraph boundaries. + * + * Markdown tables are kept intact: a table split across chunks leaves a body of + * `| --- |` rows with no header, which is both unreadable in a result and + * meaningless to embed. Tables carry real answers in this bundle (the role matrix + * in concepts/roles-and-permissions.md is one), so an over-long table is allowed + * to exceed MAX_CHUNK_CHARS rather than be broken. + */ +function splitOversized(content: string): string[] { + if (content.length <= MAX_CHUNK_CHARS) return [content]; + + const blocks = content.split(/\n\s*\n/); + const chunks: string[] = []; + let current: string[] = []; + let currentLen = 0; + + for (const block of blocks) { + const isTable = /^\s*\|/.test(block); + if (currentLen > 0 && currentLen + block.length > MAX_CHUNK_CHARS && !isTable) { + chunks.push(current.join('\n\n')); + current = []; + currentLen = 0; + } + current.push(block); + currentLen += block.length + 2; + } + if (current.length > 0) chunks.push(current.join('\n\n')); + + // A single block can still exceed the budget on its own — the changelog pages + // are one unbroken bullet list per release with no blank lines, so paragraph + // splitting cannot touch them. Left whole, everything past the model's ~256 + // tokens would be truncated away and become unsearchable, so fall back to + // packing whole LINES. Contiguous table rows stay together (see above). + const final: string[] = []; + for (const chunk of chunks) { + if (chunk.length <= MAX_CHUNK_CHARS) { + final.push(chunk); + continue; + } + let buf: string[] = []; + let len = 0; + for (const line of chunk.split('\n')) { + const isTableRow = /^\s*\|/.test(line); + if (len > 0 && len + line.length > MAX_CHUNK_CHARS && !isTableRow) { + final.push(buf.join('\n')); + buf = []; + len = 0; + } + buf.push(line); + len += line.length + 1; + } + if (buf.length > 0) final.push(buf.join('\n')); + } + + // Fold a runt tail back into its predecessor. + if (final.length > 1 && final[final.length - 1].length < MIN_CHUNK_CHARS) { + const tail = final.pop()!; + final[final.length - 1] += `\n\n${tail}`; + } + return final; +} + +// ── File walking ───────────────────────────────────────────── + +async function walkMarkdown(root: string, dir = root): Promise { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true, encoding: 'utf-8' }); + } catch { + return []; + } + const files: string[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + files.push(...(await walkMarkdown(root, join(dir, entry.name)))); + } else if (entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) { + files.push(relative(root, join(dir, entry.name))); + } + } + return files; +} + +// ── Public API ─────────────────────────────────────────────── + +/** + * Load and chunk the customer documentation bundle at `docsRoot`. + * + * Returns an empty array when the directory is absent or holds no markdown — + * a local checkout without the docs repo is a supported state, and the caller + * then simply does not register `search_docs`. A missing corpus must never be + * a boot failure. + */ +export async function loadDocsCorpus(docsRoot: string): Promise { + const files = (await walkMarkdown(docsRoot)).sort(); + const docs: SearchDocument[] = []; + const usedIds = new Set(); + + for (const relPath of files) { + let raw: string; + try { + raw = await readFile(join(docsRoot, relPath), 'utf-8'); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter(raw); + const posixPath = relPath.split(sep).join('/'); + + // Title: frontmatter wins (the technical bundle carries it), then the page's + // own h1 (the customer bundle's only source), then the filename. + const h1 = /^#\s+(.+)$/m.exec(body); + const title = + frontmatter.title ?? + h1?.[1].replace(/[*_`]/g, '').trim() ?? + posixPath.replace(/\.md$/, '').split('/').pop()!; + + // Top-level directory doubles as the bundle's section taxonomy + // (getting-started, concepts, portal-guide, …). Root files are 'general'. + const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general'; + const url = docUrl(relPath); + + // Drop the h1 line itself — it is carried in every chunk's header instead. + const withoutH1 = body.replace(/^#\s+.+$/m, ''); + const sections = splitIntoSections(stripGitbookSyntax(withoutH1)); + + for (const sec of sections) { + for (const piece of splitOversized(sec.content)) { + const trail = [title, ...sec.breadcrumb].join(' › '); + // The breadcrumb is prepended to the embedded text so a chunk lifted out + // of the middle of a page still states which page and section it is from — + // both for the embedding and for a human reading the tool result. + if (piece.replace(/[^A-Za-z0-9]/g, '').length < MIN_BODY_CHARS) continue; + const text = `${trail}\n\n${piece}`.trim(); + + const base = `docs:${posixPath}#${slugify(sec.breadcrumb.join('-')) || 'intro'}`; + let id = base; + for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`; + usedIds.add(id); + + docs.push({ + id, + text, + metadata: { + type: 'doc', + title, + section, + path: posixPath, + heading: sec.breadcrumb.join(' › ') || null, + docType: frontmatter.type ?? null, + tags: frontmatter.tags ?? [], + url: url ? (sec.anchor ? `${url}#${sec.anchor}` : url) : null, + }, + }); + } + } + } + + return docs; +} + +/** Distinct top-level sections present in a loaded corpus — drives the tool's filter enum. */ +export function docSections(docs: SearchDocument[]): string[] { + return [...new Set(docs.map((d) => String(d.metadata.section)))].sort(); +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts index b46a4b58..3b819dbd 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/index.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts @@ -9,6 +9,7 @@ import { registerTools } from './tools.js'; import { registerResources } from './resources.js'; import { registerPrompts } from './prompts.js'; import { loadKnowledgeDocs } from './knowledge-base.js'; +import { loadDocsCorpus, docSections } from './docs-corpus.js'; import { createSearchProvider, resolveBackend } from './search-factory.js'; import type { SearchBackend, SearchProvider } from './search-provider.js'; import { MODEL_NAME, VectorSearchProvider, embed } from './vector-search-provider.js'; @@ -27,6 +28,18 @@ const SERVER_VERSION: string = pkg.version; const PORT = parseInt(process.env.PORT ?? '8080', 10); const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..', '..', 'rules'); +// Published customer documentation bundle (autopilotmonitor-docs), baked into the +// image by the deploy workflow. Unset in a plain local checkout — the docs live in +// a separate repo — in which case the corpus is empty and search_docs is simply not +// registered. Point DOCS_DIR at a local clone to develop against it. +const DOCS_DIR = process.env.DOCS_DIR ?? ''; + +// Commit of the docs bundle this image was built from. The docs repo changes +// independently of this one, so a deployed image can silently serve stale +// documentation; surfacing the SHA on /health makes that checkable instead of a +// thing to remember. +const DOCS_COMMIT = process.env.DOCS_COMMIT ?? 'unknown'; + // Surface the MCP_PUBLIC_URL state at boot. In production a missing pin is a // hard boot failure (config.ts throws — host-spoofing defense); this dev-only // notice flags the forwarded-header fallback. Two-stage deploy expectation: @@ -49,10 +62,15 @@ console.error(`[startup] Backend API base URL: ${API_BASE_URL}`); console.error('Loading knowledge base documents…'); const docs = await loadKnowledgeDocs(RULES_DIR); const eventTypeDocs = buildEventTypeSearchDocs(); +const docsDocs = DOCS_DIR ? await loadDocsCorpus(DOCS_DIR) : []; +if (DOCS_DIR && docsDocs.length === 0) { + console.error(`[startup] DOCS_DIR=${DOCS_DIR} yielded no documents — search_docs will NOT be registered.`); +} async function buildSearchIndexes(backend?: SearchBackend): Promise<{ knowledgeBase: SearchProvider; eventTypeIndex: SearchProvider; + docsIndex: SearchProvider | undefined; }> { const kb = await createSearchProvider(backend); await kb.index(docs); @@ -61,7 +79,15 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{ // the embedder singleton with the knowledge base, so this adds ~no memory. const et = await createSearchProvider(backend); await et.index(eventTypeDocs); - return { knowledgeBase: kb, eventTypeIndex: et }; + // Product documentation — a third corpus, deliberately not merged into the + // knowledge base (rules answer "why did this fail", docs answer "how does this + // work"; sharing one topK would dilute both). + let dx: SearchProvider | undefined; + if (docsDocs.length > 0) { + dx = await createSearchProvider(backend); + await dx.index(docsDocs); + } + return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx }; } /** @@ -72,7 +98,11 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{ */ const SEARCH_INDEX_PATH = process.env.SEARCH_INDEX_PATH ?? resolve(__dirname, '..', 'search-index.json'); -function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventTypeIndex: SearchProvider } | null { +function tryLoadPrecomputedIndexes(): { + knowledgeBase: SearchProvider; + eventTypeIndex: SearchProvider; + docsIndex: SearchProvider | undefined; +} | null { let raw: string; try { raw = readFileSync(SEARCH_INDEX_PATH, 'utf-8'); @@ -81,7 +111,7 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType return null; } try { - const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs); + const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs, docsDocs); if (!validated.ok) { console.error(`[startup] Precomputed search index rejected (${validated.reason}) — computing embeddings at boot.`); return null; @@ -90,7 +120,13 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType kb.indexPrecomputed(validated.knowledgeBase); const et = new VectorSearchProvider(); et.indexPrecomputed(validated.eventTypes); - return { knowledgeBase: kb, eventTypeIndex: et }; + let dx: SearchProvider | undefined; + if (validated.docs.length > 0) { + const provider = new VectorSearchProvider(); + provider.indexPrecomputed(validated.docs); + dx = provider; + } + return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx }; } catch (err) { console.error('[startup] Failed to read precomputed search index — computing embeddings at boot:', err); return null; @@ -106,9 +142,10 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType console.error(`Initializing search provider (${docs.length} documents)…`); let knowledgeBase: SearchProvider; let eventTypeIndex: SearchProvider; +let docsIndex: SearchProvider | undefined; const precomputed = (await resolveBackend()) === 'vector' ? tryLoadPrecomputedIndexes() : null; if (precomputed) { - ({ knowledgeBase, eventTypeIndex } = precomputed); + ({ knowledgeBase, eventTypeIndex, docsIndex } = precomputed); // Serving precomputed vectors does not need the embedder — only incoming // queries do. Warm it in the background so neither boot nor the readiness // probe waits on the model load; a search arriving first awaits the same @@ -119,14 +156,26 @@ if (precomputed) { ); } else { try { - ({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes()); + ({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes()); } catch (err) { console.error('[startup] Search index initialization failed (embedding model unavailable?) — falling back to the keyword (fuse) backend:', err); - ({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes('fuse')); + ({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes('fuse')); } } console.error(`Search provider ready: ${knowledgeBase.name} — ${knowledgeBase.size} documents indexed${precomputed ? ' (precomputed)' : ''}.`); console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeIndex.size} types indexed${precomputed ? ' (precomputed)' : ''}.`); +if (docsIndex) { + console.error( + `Docs index ready: ${docsIndex.name} — ${docsIndex.size} chunks indexed${precomputed ? ' (precomputed)' : ''} ` + + `(docs commit ${DOCS_COMMIT}).`, + ); +} else { + console.error('Docs index: not available — search_docs is not registered.'); +} + +// Computed once: the tool catalog is rebuilt per request, and this is a pure +// function of the (immutable) corpus. +const DOCS_SECTIONS = docSections(docsDocs); // Server-level guidance. The host surfaces this once per connection, so it is // the right home for cross-cutting strategy that would otherwise be duplicated @@ -154,6 +203,11 @@ function buildInstructions(ga: boolean, delegated: boolean, managedTenants: stri 'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.', '', 'Investigating one session: call get_session_summary FIRST (status, filtered timeline, stats, rule analysis in one call), then drill in.', + ...(docsIndex + ? ['Product questions ("how do I…", "what does X mean", "where is my data stored"): use search_docs — the ' + + 'published customer documentation. search_knowledge is a DIFFERENT corpus (analysis rules and IME log ' + + 'patterns) and answers why an enrollment failed, not how the product works.'] + : []), 'Searching events: use search_events (hybrid keyword+semantic ranking; depth="fast" then "deep" for exhaustive recall) for ranked hits, or get_session_events / query_raw_events for the raw unranked stream.', 'Counting / aggregating: pass a lean `fields=` projection and use `agentVersionPrefix=`/`imeAgentVersionPrefix=` sweeps to stay under the per-response size cap.', 'Pagination: when a response carries `nextLink`, pass that whole string back as `continuation`; stop when it is absent. Results are never silently truncated.', @@ -173,7 +227,7 @@ function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, man { name: 'Autopilot-Monitor', version: SERVER_VERSION }, { instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) }, ); - registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated); + registerTools(s, knowledgeBase, eventTypeIndex, docsIndex, DOCS_SECTIONS, ga, strictGa, delegated); registerResources(s); // A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) — // the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. @@ -227,8 +281,19 @@ app.use(createOAuthRouter()); // The version is the same value already advertised in the MCP handshake // (createMcpServer → { name, version }), so surfacing it here leaks nothing // new; it lets the backend health dashboard show the deployed MCP build. +// +// The docs block exists for one reason: the documentation bundle lives in its own +// repo and is baked in at image build time, so editing the docs does NOT update a +// running server — only a redeploy does. Reporting the bundle's commit and chunk +// count turns "did we remember to redeploy?" into a diff against +// `git rev-parse HEAD` in autopilotmonitor-docs. Both values are public +// (the bundle is a public repo published at docs.autopilotmonitor.com). app.get('/health', (_req, res) => { - res.json({ status: 'healthy', version: SERVER_VERSION }); + res.json({ + status: 'healthy', + version: SERVER_VERSION, + docs: { commit: DOCS_COMMIT, chunks: docsIndex?.size ?? 0, sections: docSections(docsDocs) }, + }); }); // Access guard for /mcp — validates JWT, checks backend whitelist, enforces rate limits diff --git a/src/McpServer/autopilot-monitor-mcp/src/precompute-embeddings.ts b/src/McpServer/autopilot-monitor-mcp/src/precompute-embeddings.ts index a141197b..da30a134 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/precompute-embeddings.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/precompute-embeddings.ts @@ -7,18 +7,19 @@ * 0.25 vCPU container. Running the real pipeline here also doubles as the * model prefetch into HF_CACHE_DIR (no HuggingFace CDN dependency at runtime). * - * Usage: node dist/precompute-embeddings.js + * Usage: node dist/precompute-embeddings.js [docsDir] */ import { writeFileSync } from 'node:fs'; import { loadKnowledgeDocs } from './knowledge-base.js'; +import { loadDocsCorpus } from './docs-corpus.js'; import { buildEventTypeSearchDocs } from './resource-catalog.js'; import { MODEL_NAME, VectorSearchProvider } from './vector-search-provider.js'; import { hashDocs, type PrecomputedIndexFile } from './precomputed-index.js'; -const [rulesDir, outFile] = process.argv.slice(2); +const [rulesDir, outFile, docsDir] = process.argv.slice(2); if (!rulesDir || !outFile) { - console.error('usage: node dist/precompute-embeddings.js '); + console.error('usage: node dist/precompute-embeddings.js [docsDir]'); process.exit(2); } @@ -31,6 +32,17 @@ if (knowledgeDocs.length === 0) { } const eventTypeDocs = buildEventTypeSearchDocs(); +// Docs are optional: the production image always passes docsDir (the workflow +// checks the docs repo out into the build context), but a local build without it +// must still produce a usable index rather than fail. Warn loudly instead — +// a silently doc-less production image would leave search_docs unregistered. +const docsDocs = docsDir ? await loadDocsCorpus(docsDir) : []; +if (docsDir && docsDocs.length === 0) { + console.error(`WARNING: docs dir ${docsDir} yielded no documents — search_docs will not be registered`); +} else if (!docsDir) { + console.error('no docsDir argument — building an index without the documentation corpus'); +} + const knowledgeBase = new VectorSearchProvider(); await knowledgeBase.index(knowledgeDocs); const eventTypeIndex = new VectorSearchProvider(); @@ -41,7 +53,15 @@ const file: PrecomputedIndexFile = { knowledgeBase: { docsHash: hashDocs(knowledgeDocs), entries: knowledgeBase.serialize() }, eventTypes: { docsHash: hashDocs(eventTypeDocs), entries: eventTypeIndex.serialize() }, }; + +if (docsDocs.length > 0) { + const docsIndex = new VectorSearchProvider(); + await docsIndex.index(docsDocs); + file.docs = { docsHash: hashDocs(docsDocs), entries: docsIndex.serialize() }; +} + writeFileSync(outFile, JSON.stringify(file)); console.error( - `precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types (${MODEL_NAME}) → ${outFile}`, + `precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types + ` + + `${docsDocs.length} doc chunks (${MODEL_NAME}) → ${outFile}`, ); diff --git a/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts b/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts index 7c5c0282..e3e98786 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts @@ -29,6 +29,12 @@ export interface PrecomputedIndexFile { model: string; knowledgeBase: PrecomputedSection; eventTypes: PrecomputedSection; + /** + * Chunked customer documentation (see docs-corpus.ts). Optional in the file + * format: a build without the docs repo checked out ships no docs section, and + * that must not invalidate the other two. + */ + docs?: PrecomputedSection; } /** @@ -51,7 +57,12 @@ export function hashDocs(docs: SearchDocument[]): string { } export type PrecomputedValidation = - | { ok: true; knowledgeBase: PrecomputedDocument[]; eventTypes: PrecomputedDocument[] } + | { + ok: true; + knowledgeBase: PrecomputedDocument[]; + eventTypes: PrecomputedDocument[]; + docs: PrecomputedDocument[]; + } | { ok: false; reason: string }; function validateSection( @@ -88,6 +99,7 @@ export function validatePrecomputedIndex( expectedModel: string, knowledgeDocs: SearchDocument[], eventTypeDocs: SearchDocument[], + docsDocs: SearchDocument[] = [], ): PrecomputedValidation { if (parsed === null || typeof parsed !== 'object') return { ok: false, reason: 'not an object' }; const file = parsed as Partial; @@ -98,5 +110,24 @@ export function validatePrecomputedIndex( if (kbError) return { ok: false, reason: kbError }; const etError = validateSection(file.eventTypes, 'eventTypes', eventTypeDocs); if (etError) return { ok: false, reason: etError }; - return { ok: true, knowledgeBase: file.knowledgeBase!.entries, eventTypes: file.eventTypes!.entries }; + + // Docs are an OPTIONAL corpus: a checkout without the docs repo loads zero doc + // documents, and the matching image ships no docs section. Only validate when + // the booting server actually has a docs corpus to check against — otherwise a + // dev build would throw away the rules and event-type vectors too, which is the + // expensive half. When the server HAS docs but the file does not, that is a + // genuine staleness signal and must be rejected like any other mismatch. + let docs: PrecomputedDocument[] = []; + if (docsDocs.length > 0) { + const docsError = validateSection(file.docs, 'docs', docsDocs); + if (docsError) return { ok: false, reason: docsError }; + docs = file.docs!.entries; + } + + return { + ok: true, + knowledgeBase: file.knowledgeBase!.entries, + eventTypes: file.eventTypes!.entries, + docs, + }; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools.ts b/src/McpServer/autopilot-monitor-mcp/src/tools.ts index 314a0d5a..cc53fc07 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools.ts @@ -26,12 +26,14 @@ export function registerTools( server: McpServer, knowledgeBase: SearchProvider | undefined, eventTypeIndex: SearchProvider | undefined, + docsIndex: SearchProvider | undefined, + docsSections: string[], ga: boolean, strictGa: boolean = ga, delegated: boolean = false, ): void { registerSessionTools(server, ga, delegated); - registerSearchTools(server, knowledgeBase, eventTypeIndex, ga, delegated); + registerSearchTools(server, knowledgeBase, eventTypeIndex, docsIndex, docsSections, ga, delegated); registerAdminTools(server, ga, strictGa, delegated); sortToolCatalog(server); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index 6da575f8..32ece7cb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -965,6 +965,9 @@ export function registerSearchTools( server: McpServer, knowledgeBase: SearchProvider | undefined, eventTypeIndex: SearchProvider | undefined, + docsIndex: SearchProvider | undefined, + /** Top-level documentation areas present in the corpus — surfaced as the `section` filter hint. */ + docsSections: string[], ga: boolean, delegated: boolean = false, ): void { @@ -1176,4 +1179,95 @@ export function registerSearchTools( }) ); + // Tool 11: search_docs — semantic search over the published customer documentation. + // + // Registered for EVERY role, unlike the platform tools: this is public product + // documentation (docs.autopilotmonitor.com), carries no tenant data, and a + // normal tenant user has exactly as much business asking "how do I deploy the + // agent" as a Global Admin. It is skipped entirely when no corpus was baked + // into the image, so a doc-less build advertises no broken tool. + if (docsIndex && docsIndex.size > 0) { + server.registerTool( + 'search_docs', + { + title: 'Search Product Documentation', + description: + 'PRODUCT DOCUMENTATION SEARCH — the published Autopilot Monitor customer documentation ' + + '(docs.autopilotmonitor.com), chunked by section and ranked semantically. ' + + 'Use this for questions about how the PRODUCT works rather than what a specific enrollment did: ' + + 'setup and onboarding, agent deployment, portal features, roles and permissions, settings, ' + + 'notifications, network endpoints, security/privacy/data-residency, plans, and troubleshooting guidance. ' + + 'Each result carries the page `title`, its `heading` breadcrumb and a citable `url` — quote the url when ' + + 'answering so the user can verify. ' + + 'NOT the same corpus as search_knowledge: that one holds analysis/gather rules and IME log patterns and ' + + 'explains why an enrollment failed. Rule of thumb — "how does X work?" → search_docs; ' + + '"why did this session fail?" → search_knowledge or get_session_summary.', + inputSchema: { + query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'), + topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'), + section: z.string().optional() + .describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' + + `(one of: ${docsSections.join(', ')}). Omit to search everything.`), + minScore: z.coerce.number().min(0).max(1).optional().default(0.25) + .describe('Minimum similarity score (0-1, default 0.25). Short keyword queries score low on ' + + 'all-MiniLM embeddings, so the default is tuned to keep the relevant-but-marginal band.'), + }, + annotations: READ_ONLY, + }, + async (args) => withToolTelemetry('search_docs', async () => { + try { + const { query, topK, section, minScore } = args; + + // Over-fetch before filtering by section, exactly as search_knowledge does + // for `type` — filtering a topK-sized result set would otherwise return + // fewer than topK hits whenever the top matches sit in other sections. + const fetchK = section ? topK * 4 : topK; + let results = await docsIndex.search(query, { topK: fetchK, minScore }); + + // Same rationale as search_knowledge: opaque HRESULT/Win32 tokens embed + // poorly, and the troubleshooting pages name them verbatim. + const needles = extractErrorCodeNeedles(query); + const errorCodeHitIds = new Set(); + if (needles.length > 0 && docsIndex.lexicalMatch) { + const byId = new Map(results.map((r) => [r.id, r] as const)); + for (const hit of docsIndex.lexicalMatch(needles)) { + errorCodeHitIds.add(hit.id); + const existing = byId.get(hit.id); + if (!existing || hit.score > existing.score) byId.set(hit.id, hit); + } + results = [...byId.values()].sort((a, b) => b.score - a.score); + } + + if (section) { + results = results.filter((r) => r.metadata.section === section); + } + results = results.slice(0, topK); + + const formatted = results.map((r) => ({ + id: r.id, + score: Math.round(r.score * 1000) / 1000, + title: r.metadata.title, + heading: r.metadata.heading, + section: r.metadata.section, + path: r.metadata.path, + url: r.metadata.url, + tags: r.metadata.tags, + content: r.text, + matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined, + })); + + return toolResultText({ + query, + searchBackend: docsIndex.name, + ...(section ? { section } : {}), + resultCount: formatted.length, + ...(needles.length > 0 ? { errorCodeFallback: { codes: needles, matchedCount: errorCodeHitIds.size } } : {}), + results: formatted, + }, MAX_RESULT_SIZE_CHARS.small); + } catch (error: unknown) { + return toolError('search_docs', args, error); + } + }) + ); + } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/vector-search-provider.ts b/src/McpServer/autopilot-monitor-mcp/src/vector-search-provider.ts index c6ecfff4..5f06c8ff 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/vector-search-provider.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/vector-search-provider.ts @@ -56,6 +56,13 @@ function getEmbedder(): Promise { */ const UNIT_NORM_EPSILON = 1e-3; +/** + * Decimal places kept when serializing embeddings to the precomputed index. + * Six is comfortably below the tolerance above while roughly halving the file + * size — see VectorSearchProvider.serialize(). + */ +const SERIALIZED_PRECISION = 6; + export function assertUnitNorm(vec: number[], context: string): void { let sumSq = 0; for (let i = 0; i < vec.length; i++) sumSq += vec[i] * vec[i]; @@ -119,12 +126,31 @@ export class VectorSearchProvider implements SearchProvider { * from disk is milliseconds. The embedder is still loaded lazily for queries. */ indexPrecomputed(docs: PrecomputedDocument[]): void { + // The serialized vectors are rounded (see serialize()) and arrive from a file + // this process did not produce. Spot-check the first one: cosineSimilarity is + // a bare dot product, so a non-unit vector here would silently skew every + // score rather than fail. One check is enough — the whole section is written + // by a single serialize() call, so they stand or fall together. + if (docs.length > 0) assertUnitNorm(docs[0].embedding, 'indexPrecomputed'); this.documents.push(...docs); } - /** Export the indexed documents with embeddings for build-time serialization. */ + /** + * Export the indexed documents with embeddings for build-time serialization. + * + * Embeddings are rounded to SERIALIZED_PRECISION decimals. A float64 component + * serializes to ~20 characters of JSON ("0.05234159901738167"), so 384 dims + * cost ~7.8 KB per document; rounding roughly halves that, and the index file + * is JSON.parse'd on every scale-to-zero cold start. The precision loss is + * irrelevant to ranking: the induced L2 norm error is ~1e-5 across 384 dims, + * two orders of magnitude inside UNIT_NORM_EPSILON, and far below the score + * gaps that separate ranked results. + */ serialize(): PrecomputedDocument[] { - return this.documents; + return this.documents.map((doc) => ({ + ...doc, + embedding: doc.embedding.map((v) => Number(v.toFixed(SERIALIZED_PRECISION))), + })); } async index(docs: SearchDocument[]): Promise {