feat(mcp): search_docs — customer documentation as a third search corpus

The MCP server could answer telemetry questions ("why did this session fail")
but not product ones ("how do I deploy the agent", "where is my data stored").
Those answers already exist in the autopilotmonitor-docs bundle; search_docs
makes them semantically searchable.

Deliberately a separate index from search_knowledge: rules and prose answer
different questions, and sharing one topK would dilute both. Registered for
every role — published documentation carries no tenant data — and skipped
entirely when no corpus was baked in, so a doc-less build advertises no
broken tool.

Corpus handling:
- Chunked at h2/h3 and <details>/<summary>, each chunk prefixed with a
  "Page > Section > Subsection" breadcrumb so a mid-page hit is self-describing.
- Newlines are normalized before any parsing. This is a correctness
  precondition, not cosmetics: the bundle ships CRLF and JS `.` does not match
  \r, so /^(#{2,3})\s+(.*)$/ matched zero headings — silently, degrading every
  page to unlabelled paragraph splits.
- Two size exceptions: tables are never split (a split table leaves `| --- |`
  rows with no header), and unbroken changelog lists split at line level (left
  whole, their tails fall outside the model window and become unsearchable).
- Short sections are kept — a one-line answer is often the best hit, and the
  breadcrumb supplies the context a short body lacks.

Build and delivery:
- Docs are embedded at image build time into an optional third
  PrecomputedSection. Optional so a checkout without the docs repo does not
  invalidate the rules and event-type vectors; the reverse (server has docs,
  file does not) is staleness and is rejected.
- The deploy workflow checks the public docs repo into the build context and
  passes its SHA as DOCS_COMMIT, which /health reports alongside the chunk
  count — editing the docs does not update a running server, so staleness is
  made checkable rather than memorable.
- Embeddings are rounded to 6 decimals on serialize: 55% smaller index (2.63
  vs 5.88 MB) for a ~1e-5 norm error against a 1e-3 tolerance. indexPrecomputed
  now asserts unit norm, since the hydrate path never checked it.

Infra: the Bicep template was two changes behind the live deployment (EU
cutover and ACR hardening, both applied directly in Azure). Reconciled against
the running resources — managed-identity image pull replaces the admin
credentials and the acr-password secret — and infra/ is no longer gitignored so
it stops drifting. Container raised to 0.5 vCPU / 1.0 GiB for the cold-start
query-embedder load. The template does not model the mcp.autopilotmonitor.com
custom domain, so a full deployment would remove it; the header warns about it.

Verified: 428 tests pass; 45 pages produce 324 chunks; sample queries hit the
right page top-1/2; all 8 derived URLs return 200 live; the precompute output
is accepted by the boot validator and a mutated corpus is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-21 19:48:28 +02:00
parent 93dcac44a4
commit 63ea2e3cee
19 changed files with 1521 additions and 25 deletions
+17
View File
@@ -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 \
.
+5 -2
View File
@@ -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/
+4
View File
@@ -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.
+4
View File
@@ -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 + `<details>` 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).
+159
View File
@@ -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.
`<details><summary>Q</summary>` 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 <rulesDir> <outFile> [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
+260
View File
@@ -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 <rg-name> \
// --template-file infra/mcp-server.bicep \
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
// entraClientSecret=<secret-value>
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.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=<containerAppUrl from step 1>
//
// 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 <acr-id> --assignee <containerApp principalId>
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
+25 -1
View File
@@ -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
@@ -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<T>(
files: Record<string, string>,
fn: (root: string) => Promise<T>,
): Promise<T> {
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 <details>/<summary> blocks as sections', async () => {
const chunks = await withBundle(
{
'troubleshooting/faq.md': page(
'type: FAQ\ntags: [faq]',
['# FAQ', '', '## General', '', '<details>', '', '<summary>Is Autopilot Monitor free?</summary>', '', 'Yes, the Community plan is free and stays free for everyone who wants to use it.', '', '</details>'].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']);
});
});
@@ -14,8 +14,19 @@ const MODEL = 'Xenova/all-MiniLM-L6-v2';
const doc = (id: string, text: string, metadata: Record<string, unknown> = {}): 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])));
@@ -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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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);
@@ -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<string, { handler: ToolHandler }> })._registeredTools;
const tool = registry[name];
if (!tool) throw new Error(`tool ${name} not registered for this caller shape`);
@@ -29,7 +29,7 @@ type ToolHandler = (args: Record<string, unknown>, 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<string, { handler: ToolHandler }> })._registeredTools;
const tool = registry[name];
if (!tool) throw new Error(`tool ${name} not registered for strict-GA caller`);
@@ -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<string, string | string[]> = {};
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
* `<details><summary>Q</summary>…</details>` 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>(.*?)<\/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*<details>\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<string[]> {
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<SearchDocument[]> {
const files = (await walkMarkdown(docsRoot)).sort();
const docs: SearchDocument[] = [];
const usedIds = new Set<string>();
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();
}
@@ -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
@@ -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 <rulesDir> <outFile>
* Usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [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 <rulesDir> <outFile>');
console.error('usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [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}`,
);
@@ -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<PrecomputedIndexFile>;
@@ -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,
};
}
@@ -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);
}
@@ -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<string>();
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);
}
})
);
}
}
@@ -56,6 +56,13 @@ function getEmbedder(): Promise<FeatureExtractionPipeline> {
*/
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<void> {