From 556cace97c972217da06d9f55c7d4cff5de8a642 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 21 Oct 2026 14:57:05 +0200 Subject: [PATCH] feat(mcp): generate a search index and serve it from ports and adapters Build step 3b extracts every page's untrusted HTML into bounded text (mcp/build/) and writes dist/_mcp/search-index.json, kept out of the image's web root by .dockerignore. mcp/search-index/schema.ts is the file format, the only code build and runtime share. The runtime domain sits behind DocumentRepository and SearchBackend ports with a KnowledgeBase facade; adapters/memory/ implements both over the search index (stemmed lexical ranking, title boost, compound split, fuzzy match). A future backend such as OpenSearch is a new adapter pair that must pass tests/support/backend-contract.js. TypeScript limited to erasable syntax so Node runs it as-is; linted by tsc, ESLint strictTypeChecked and Prettier (npm run lint, new CI job), with an import boundary keeping build-time code out of the runtime. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 4 + .github/workflows/ci.yml | 15 + .prettierrc.json | 5 + eslint.config.js | 42 + mcp/build/extract.ts | 277 ++++ mcp/build/index-builder.ts | 168 +++ mcp/search-index/schema.ts | 106 ++ mcp/src/adapters/memory/cursor.ts | 43 + mcp/src/adapters/memory/lexical-search.ts | 174 +++ .../memory/search-index-repository.ts | 60 + mcp/src/adapters/memory/text.ts | 71 + mcp/src/compose.ts | 15 + mcp/src/domain.ts | 76 ++ mcp/src/knowledge-base.ts | 204 +++ mcp/src/ports.ts | 34 + mcp/tsconfig.json | 29 + package-lock.json | 1141 ++++++++++++++++- package.json | 11 +- playwright.config.mcp.js | 14 + scripts/build-mcp.js | 16 + scripts/build-vite.js | 29 +- tests/fixtures/mcp/synthetic-index.js | 86 ++ tests/mcp-backend-contract.spec.js | 8 + tests/mcp-extract.spec.js | 29 + tests/mcp-index-builder.spec.js | 16 + tests/mcp-search.spec.js | 69 + tests/support/backend-contract.js | 130 ++ tsconfig.json | 2 +- 28 files changed, 2848 insertions(+), 26 deletions(-) create mode 100644 .prettierrc.json create mode 100644 eslint.config.js create mode 100644 mcp/build/extract.ts create mode 100644 mcp/build/index-builder.ts create mode 100644 mcp/search-index/schema.ts create mode 100644 mcp/src/adapters/memory/cursor.ts create mode 100644 mcp/src/adapters/memory/lexical-search.ts create mode 100644 mcp/src/adapters/memory/search-index-repository.ts create mode 100644 mcp/src/adapters/memory/text.ts create mode 100644 mcp/src/compose.ts create mode 100644 mcp/src/domain.ts create mode 100644 mcp/src/knowledge-base.ts create mode 100644 mcp/src/ports.ts create mode 100644 mcp/tsconfig.json create mode 100644 playwright.config.mcp.js create mode 100644 scripts/build-mcp.js create mode 100644 tests/fixtures/mcp/synthetic-index.js create mode 100644 tests/mcp-backend-contract.spec.js create mode 100644 tests/mcp-extract.spec.js create mode 100644 tests/mcp-index-builder.spec.js create mode 100644 tests/mcp-search.spec.js create mode 100644 tests/support/backend-contract.js diff --git a/.dockerignore b/.dockerignore index b65605c..c986db4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,7 @@ vite.config.js tsconfig.json package-lock.json apps.json + +# The MCP search index is runtime data for the MCP server, not public static +# content: keep it out of the nginx web root. +dist/_mcp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bc5030..2ab341a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ # # Jobs: # typecheck — astro check (TypeScript / Astro type errors) +# lint — MCP server: tsc, ESLint, Prettier (npm run lint) # build — headless build from the vendored fixture; uploads dist/ # e2e — Playwright: embedded web-fragment harness + standalone layer # image — docker build + container integration tests against real nginx + Trivy scan @@ -47,6 +48,20 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=6144 + # ── 1b. Lint (MCP server) ────────────────────────────────────────────────── + # Blocking: tsc --noEmit, ESLint (type-aware) and Prettier over mcp/. + lint: + name: Lint (MCP) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm run lint + # ── 2. Build (headless, hermetic) ────────────────────────────────────────── build: name: Build (headless) diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4b9a2d9 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4a88cf2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +// Lints the MCP server (mcp/). The rest of the repo is not linted yet. +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['**/node_modules/**', 'dist/**', 'apps/**', 'public/**', 'tmp/**'] }, + { + files: ['mcp/**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], + languageOptions: { + globals: globals.node, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + // Adapters implement async ports without awaiting anything; that is the point of the port. + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }], + eqeqeq: 'error', + 'no-console': 'error', + }, + }, + { + // The runtime bundle must not pull in build-time code: parse5, the registry and the + // filesystem crawl stay in mcp/build/. + files: ['mcp/src/**/*.ts', 'mcp/search-index/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [{ name: 'parse5', message: 'HTML extraction is build-time only (mcp/build/).' }], + patterns: [ + { group: ['**/build/**', '**/build/*.ts'], message: 'Runtime code must not import mcp/build/.' }, + { group: ['**/src/utils/**'], message: 'Runtime code must not import the Astro build utilities.' }, + ], + }, + ], + }, + }, +); diff --git a/mcp/build/extract.ts b/mcp/build/extract.ts new file mode 100644 index 0000000..61797fd --- /dev/null +++ b/mcp/build/extract.ts @@ -0,0 +1,277 @@ +// Turns untrusted artifact HTML into bounded, markdown-like plain text. Build time only: +// the server never parses HTML. +import { parse } from 'parse5'; +import type { IndexedSection } from '../search-index/schema.ts'; + +/** The slice of parse5's default tree this walker reads. */ +interface HtmlNode { + nodeName: string; + tagName?: string; + value?: string; + attrs?: { name: string; value: string }[]; + childNodes?: HtmlNode[]; +} + +export interface ExtractOptions { + fallbackTitle?: string; + /** A manifest-declared page title; wins over anything in the HTML. */ + pageTitle?: string; + /** The app description, used when no paragraph is long enough to describe the page. */ + description?: string; + warn?: (message: string) => void; +} + +export interface ExtractedDocument { + title: string; + description: string; + text: string; + sections: IndexedSection[]; + truncated: boolean; +} + +export const MAX_TEXT_LENGTH = 500_000; +const MAX_TITLE_LENGTH = 200; +const MAX_DESCRIPTION_LENGTH = 300; +const MIN_DESCRIPTION_LENGTH = 40; + +const words = (list: string) => new Set(list.split(' ')); + +/** Never rendered, nor anything inside them. */ +const DROP = words( + 'script style noscript template svg canvas iframe object embed nav aside form button input select textarea dialog link meta', +); +/** Elements that end an inline run. Everything else (text, a, strong, span, code, img…) joins the run around it. */ +const BLOCK_LEVEL = words( + 'address article aside blockquote dd details dialog div dl dt fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr li main nav ol p pre section summary table tbody td tfoot th thead tr ul', +); +/** Page chrome that docs generators mark with a class instead of an element. */ +const HIDDEN_CLASSES = words('kb-anchor headerlink md-sidebar sidebar toc'); +/** C0 controls, DEL, zero-width characters and bidi overrides/isolates. */ +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g; +const SAFE_ANCHOR = /^[A-Za-z][\w:.-]{0,127}$/; +const CODE_LANGUAGE = /(?:^|\s)language-([a-z0-9+#-]{1,20})(?:\s|$)/i; + +const clean = (value: string | undefined): string => + (value ?? '') + .normalize('NFC') + .replace(CONTROL, '') + .replace(/[\t \f\v]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +const oneLine = (value: string): string => clean(value).replace(/\n+/g, ' '); +const attr = (node: HtmlNode, name: string): string | undefined => + node.attrs?.find((item) => item.name === name)?.value; +const tag = (node: HtmlNode): string | undefined => node.tagName?.toLowerCase(); +const children = (node: HtmlNode): HtmlNode[] => node.childNodes ?? []; +const rawText = (node: HtmlNode): string => + node.nodeName === '#text' ? (node.value ?? '') : children(node).map(rawText).join(''); +const textOnly = (node: HtmlNode): string => clean(rawText(node)); +const headingLevel = (name: string | undefined): number => (name && /^h[1-6]$/.test(name) ? Number(name[1]) : 0); + +function find(node: HtmlNode, predicate: (node: HtmlNode) => boolean): HtmlNode | null { + if (predicate(node)) return node; + for (const child of children(node)) { + const found = find(child, predicate); + if (found) return found; + } + return null; +} + +const hasHiddenClass = (node: HtmlNode): boolean => + (attr(node, 'class') ?? '').split(/\s+/).some((part) => HIDDEN_CLASSES.has(part)); + +/** The page's main content:
, role=main,
, , or the whole document. */ +const contentRoot = (document: HtmlNode): HtmlNode => + find(document, (node) => tag(node) === 'main') ?? + find(document, (node) => attr(node, 'role') === 'main') ?? + find(document, (node) => tag(node) === 'article') ?? + find(document, (node) => tag(node) === 'body') ?? + document; + +/** Walks one document. `output` grows as nodes render; sections record offsets into it. */ +class Extractor { + output = ''; + readonly sections: (Omit & { end?: number })[] = []; + readonly paragraphs: string[] = []; + private readonly root: HtmlNode; + + constructor(root: HtmlNode) { + this.root = root; + } + + /** Dropped elements, hidden ones, and header/footer chrome other than the root itself. */ + private unsafe(node: HtmlNode): boolean { + const name = tag(node); + return ( + (name !== undefined && DROP.has(name)) || + ((name === 'header' || name === 'footer') && node !== this.root) || + attr(node, 'hidden') !== undefined || + attr(node, 'aria-hidden') === 'true' || + hasHiddenClass(node) + ); + } + + private add(value: string, gap = ''): void { + const cleaned = clean(value); + if (!cleaned) return; + if (this.output && gap) this.output += gap; + this.output += cleaned; + } + + private startBlock(): void { + if (this.output) this.output += '\n\n'; + } + + private inline(node: HtmlNode): string { + if (node.nodeName === '#text') return node.value ?? ''; + if (this.unsafe(node)) return ''; + const name = tag(node); + if (name === 'br') return '\n'; + if (name === 'img') { + const alt = attr(node, 'alt'); + return alt ? `[${alt}]` : ''; + } + const value = children(node) + .map((child) => this.inline(child)) + .join(''); + if (name === 'code') return `\`${value}\``; + // A block nested where only inline text is wanted (a

in a list item or cell) still separates words. + return name !== undefined && BLOCK_LEVEL.has(name) ? ` ${value} ` : value; + } + + /** Render children, joining each run of adjacent inline nodes into one paragraph before trimming it. */ + private renderChildren(node: HtmlNode, depth: number): void { + let run = ''; + const flush = () => { + const value = clean(run); + run = ''; + if (!value) return; + this.paragraphs.push(value); + this.add(value, '\n\n'); + }; + for (const child of children(node)) { + const name = tag(child); + if (name === undefined || !BLOCK_LEVEL.has(name)) { + run += this.inline(child); + } else { + flush(); + this.render(child, depth); + } + } + flush(); + } + + private renderList(node: HtmlNode, depth: number): void { + const marker = tag(node) === 'ol' ? (i: number) => `${i}. ` : () => '- '; + let i = 0; + for (const item of children(node)) { + if (tag(item) !== 'li' || this.unsafe(item)) continue; + i += 1; + const nested = children(item).filter((child) => ['ul', 'ol', 'pre', 'table'].includes(tag(child) ?? '')); + const label = oneLine( + children(item) + .filter((child) => !nested.includes(child)) + .map((child) => this.inline(child)) + .join(''), + ); + if (label) { + // Appended as-is: add() would trim away the nesting indent. + if (this.output) this.output += i === 1 && depth === 0 ? '\n\n' : '\n'; + this.output += `${' '.repeat(depth)}${marker(i)}${label}`; + } + for (const child of nested) this.render(child, depth + 1); + } + } + + private renderHeading(node: HtmlNode, level: number): void { + const heading = oneLine(this.inline(node)); + if (!heading) return; + this.startBlock(); + const start = this.output.length; + this.output += `${'#'.repeat(level)} ${heading}`; + const id = attr(node, 'id') ?? ''; + this.sections.push({ heading, anchor: SAFE_ANCHOR.test(id) ? id : null, level, start }); + } + + /** Code keeps its whitespace: highlighter token spans are joined as-is and indentation survives. */ + private renderCode(node: HtmlNode): void { + const classes = attr(node, 'class') ?? ''; + const language = classes.includes('mermaid') ? 'mermaid' : (CODE_LANGUAGE.exec(classes)?.[1] ?? ''); + const code = rawText(node) + .normalize('NFC') + .replace(/\r\n?/g, '\n') + .replace(CONTROL, '') + .replace(/^\n+|\s+$/g, ''); + if (!code) return; + this.startBlock(); + this.output += `\`\`\`${language}\n${code}\n\`\`\``; + } + + private renderTable(node: HtmlNode): void { + const rows = children(node) + .flatMap((child) => (['thead', 'tbody', 'tfoot'].includes(tag(child) ?? '') ? children(child) : [child])) + .filter((row) => tag(row) === 'tr' && !this.unsafe(row)); + const lines = rows.map((row) => { + const cells = children(row) + .filter((cell) => ['td', 'th'].includes(tag(cell) ?? '')) + .map((cell) => oneLine(this.inline(cell))); + return `| ${cells.join(' | ')} |`; + }); + this.add(lines.join('\n'), '\n\n'); + } + + render(node: HtmlNode, depth = 0): void { + if (this.unsafe(node)) return; + const name = tag(node); + const level = headingLevel(name); + if (level) this.renderHeading(node, level); + else if (name === 'pre') this.renderCode(node); + else if (name === 'table') this.renderTable(node); + else if (name === 'ul' || name === 'ol') this.renderList(node, depth); + else if (name !== 'hr') this.renderChildren(node, depth); + } +} + +/** Turn untrusted artifact HTML into bounded markdown-like plain text. */ +export function extractDocument(html: string, options: ExtractOptions = {}): ExtractedDocument { + const { fallbackTitle = '', pageTitle = '', description = '', warn = () => undefined } = options; + const document = parse(html) as unknown as HtmlNode; + const root = contentRoot(document); + const extractor = new Extractor(root); + extractor.render(root); + + let text = extractor.output; + let truncated = false; + if (text.length > MAX_TEXT_LENGTH) { + text = text.slice(0, MAX_TEXT_LENGTH); + truncated = true; + warn(`MCP extraction truncated document at ${MAX_TEXT_LENGTH} characters`); + } + + // Each section runs to the next heading, or to the end of the (possibly truncated) text. + const sections = extractor.sections.map((section, i, all) => ({ + ...section, + end: Math.min(text.length, all[i + 1]?.start ?? text.length), + })); + + const titleNode = find(root, (node) => tag(node) === 'h1'); + const documentTitle = find(document, (node) => tag(node) === 'title'); + const title = clean( + pageTitle || + (titleNode ? textOnly(titleNode) : '') || + (documentTitle ? textOnly(documentTitle) : '') || + fallbackTitle, + ).slice(0, MAX_TITLE_LENGTH); + const extractedDescription = + extractor.paragraphs.find((paragraph) => paragraph.length >= MIN_DESCRIPTION_LENGTH) ?? description; + + return { + title, + description: clean(extractedDescription).slice(0, MAX_DESCRIPTION_LENGTH), + text, + sections: sections.filter((section) => section.start < section.end), + truncated, + }; +} diff --git a/mcp/build/index-builder.ts b/mcp/build/index-builder.ts new file mode 100644 index 0000000..013c52d --- /dev/null +++ b/mcp/build/index-builder.ts @@ -0,0 +1,168 @@ +// Builds the search index from the sub-app pages the Astro build rendered. Build time only. +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import { getAppPages, loadRegistry } from '../../src/utils/apps.js'; +import { isIframe } from '../../src/utils/registry.js'; +import { PATH_PREFIX } from '../../src/utils/config.js'; +import { SEARCH_INDEX_SCHEMA_VERSION, validateSearchIndex } from '../search-index/schema.ts'; +import type { SearchIndex, IndexedApp, IndexedDocument } from '../search-index/schema.ts'; +import { extractDocument } from './extract.ts'; +import type { ExtractedDocument } from './extract.ts'; + +/** A registry entry as `loadRegistry()` returns it, after manifest expansion. */ +interface RegistryApp { + slug: string; + name?: string; + description?: string; + tags?: string[]; + url?: string; +} + +/** A page as `getAppPages()` enumerates it. */ +interface AppPage { + slug: string; + routePath: string; + file?: string; + title?: string; + section?: string; + iframe?: boolean; +} + +export interface BuildSearchIndexOptions { + root?: string; + headless?: boolean; + /** Source version label per app slug (the release tag, or `prebuilt`/`local`). */ + versions?: Record; + now?: string; + warn?: (message: string) => void; +} + +const sha256 = (value: string) => createHash('sha256').update(value).digest('hex'); +const uriFor = (routePath: string) => `kb://${routePath.split('/').map(encodeURIComponent).join('/')}`; +const routeFor = (routePath: string) => `/${PATH_PREFIX}/${routePath}/`; +const isIndex = (page: AppPage) => basename(page.file ?? '') === 'index.html'; + +/** This tool's own name and version, not the project being indexed. */ +function generator(): string { + const file = new URL('../../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(file, 'utf8')) as { name?: string; version?: string }; + return `${pkg.name ?? 'knowledge-base'}@${pkg.version ?? '0.0.0'}`; +} + +function toIndexedApp(app: RegistryApp, versions: Record): IndexedApp { + const iframe = isIframe(app); + return { + slug: app.slug, + name: app.name ?? app.slug, + description: app.description ?? '', + tags: app.tags ?? [], + kind: iframe ? 'iframe' : 'artifact', + url: iframe ? (app.url ?? null) : null, + sourceVersion: versions[app.slug] ?? null, + }; +} + +/** + * One page per route. Two files can claim a route (`foo.html` and `foo/index.html`): + * index.html wins, otherwise the lexically first file, so the choice is deterministic. + */ +function pagesByRoute(pages: AppPage[], warn: (message: string) => void): AppPage[] { + const byRoute = new Map(); + for (const page of pages) { + const prior = byRoute.get(page.routePath); + const wins = + !prior || isIndex(page) || (!isIndex(prior) && String(page.file).localeCompare(String(prior.file)) < 0); + if (!wins) continue; + if (prior && !isIndex(page)) warn(`MCP route collision for ${page.routePath}; chose ${String(page.file)}`); + byRoute.set(page.routePath, page); + } + return [...byRoute.values()]; +} + +function extractPage(page: AppPage, app: IndexedApp, warn: (message: string) => void): ExtractedDocument { + if (page.iframe) { + const text = [`# ${app.name}`, app.description, `External documentation: ${String(app.url)}`] + .filter(Boolean) + .join('\n\n'); + return { title: app.name, description: app.description, text, sections: [], truncated: false }; + } + const html = page.file && existsSync(page.file) ? readFileSync(page.file, 'utf8') : ''; + const extracted = extractDocument(html, { + fallbackTitle: app.name, + pageTitle: page.title ?? '', + description: app.description, + warn, + }); + if (!extracted.text) { + extracted.text = [`# ${extracted.title || app.name}`, extracted.description || app.description] + .filter(Boolean) + .join('\n\n'); + warn(`MCP extraction empty for ${page.routePath}`); + } + return extracted; +} + +export function buildSearchIndex(options: BuildSearchIndexOptions = {}): SearchIndex { + const { + root = process.cwd(), + headless = false, + versions = {}, + now = new Date().toISOString(), + warn = () => undefined, + } = options; + + const apps = (loadRegistry(root) as RegistryApp[]) + .map((app) => toIndexedApp(app, versions)) + .sort((a, b) => a.slug.localeCompare(b.slug)); + const appBySlug = new Map(apps.map((app) => [app.slug, app])); + const pages = (getAppPages(root, headless) as AppPage[]).filter( + (page) => page.iframe === true || basename(page.file ?? '') !== '404.html', + ); + + const documents: IndexedDocument[] = []; + for (const page of pagesByRoute(pages, warn)) { + const app = appBySlug.get(page.slug); + if (!app) continue; + const extracted = extractPage(page, app, warn); + const { text } = extracted; + documents.push({ + uri: uriFor(page.routePath), + app: page.slug, + route: routeFor(page.routePath), + title: extracted.title || app.name, + description: extracted.description || app.description, + tags: app.tags, + section: page.section ?? null, + mimeType: 'text/markdown', + sourceVersion: app.sourceVersion ?? null, + sha256: sha256(text), + size: Buffer.byteLength(text), + truncated: extracted.truncated, + text, + sections: extracted.sections, + }); + } + documents.sort((a, b) => a.app.localeCompare(b.app) || a.route.localeCompare(b.route)); + + const seen = new Set(); + for (const document of documents) { + if (seen.has(document.uri)) throw new Error(`Duplicate MCP URI: ${document.uri}`); + seen.add(document.uri); + } + + return validateSearchIndex({ + schemaVersion: SEARCH_INDEX_SCHEMA_VERSION, + generator: generator(), + indexedAt: now, + pathPrefix: PATH_PREFIX, + contentHash: sha256(JSON.stringify(documents)), + apps, + documents, + }); +} + +export function writeSearchIndex(file: string, searchIndex: SearchIndex): void { + validateSearchIndex(searchIndex); + writeFileSync(file, JSON.stringify(searchIndex) + '\n'); +} diff --git a/mcp/search-index/schema.ts b/mcp/search-index/schema.ts new file mode 100644 index 0000000..8cb4039 --- /dev/null +++ b/mcp/search-index/schema.ts @@ -0,0 +1,106 @@ +// The search index file format: written by the build (mcp/build/), read by the server +// (mcp/src/). The one definition both sides share, so it must stay free of parse5, +// the filesystem and the Astro utilities. + +export const SEARCH_INDEX_SCHEMA_VERSION = 1; + +export interface IndexedApp { + slug: string; + name: string; + description: string; + tags: string[]; + kind?: 'artifact' | 'iframe'; + url?: string | null; + sourceVersion?: string | null; +} + +export interface IndexedSection { + heading: string; + /** The heading's id, when it is a safe fragment identifier. */ + anchor: string | null; + level: number; + /** Character offsets into the document text, `start` inclusive and `end` exclusive. */ + start: number; + end: number; +} + +export interface IndexedDocument { + /** `kb:///`, unique within the search index. */ + uri: string; + app: string; + /** Site-absolute route, e.g. `/knowledge-base///`. */ + route: string; + title: string; + description: string; + tags: string[]; + section?: string | null; + mimeType: string; + sourceVersion: string | null; + sha256: string; + size: number; + truncated: boolean; + /** Markdown-like plain text extracted from the page at build time. */ + text: string; + sections: IndexedSection[]; +} + +export interface SearchIndex { + schemaVersion: typeof SEARCH_INDEX_SCHEMA_VERSION; + generator: string; + indexedAt: string; + pathPrefix?: string; + /** Hash over every document; cursors are bound to it. */ + contentHash: string; + apps: IndexedApp[]; + documents: IndexedDocument[]; +} + +const DOCUMENT_STRING_FIELDS = ['uri', 'app', 'route', 'title', 'description', 'mimeType', 'sha256', 'text'] as const; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +function validateSection(section: unknown, text: string, uri: string): void { + if ( + !isRecord(section) || + typeof section.heading !== 'string' || + !Number.isInteger(section.start) || + !Number.isInteger(section.end) + ) { + throw new Error(`Invalid MCP search index: bad section in ${uri}`); + } + const start = section.start as number; + const end = section.end as number; + if (start < 0 || start >= end || end > text.length) + throw new Error(`Invalid MCP search index: bad section in ${uri}`); +} + +/** Check the structure the server relies on; returns the search index, typed, or throws. */ +export function validateSearchIndex(searchIndex: unknown): SearchIndex { + if ( + !isRecord(searchIndex) || + searchIndex.schemaVersion !== SEARCH_INDEX_SCHEMA_VERSION || + !Array.isArray(searchIndex.apps) || + !Array.isArray(searchIndex.documents) || + typeof searchIndex.contentHash !== 'string' + ) { + throw new Error('Invalid MCP search index: schemaVersion, apps, documents and contentHash are required'); + } + for (const app of searchIndex.apps as unknown[]) { + if (!isRecord(app) || typeof app.slug !== 'string' || typeof app.name !== 'string') { + throw new Error('Invalid MCP search index: invalid app'); + } + } + const seen = new Set(); + for (const document of searchIndex.documents as unknown[]) { + const fields = isRecord(document) ? document : {}; + for (const key of DOCUMENT_STRING_FIELDS) { + if (typeof fields[key] !== 'string') throw new Error(`Invalid MCP search index: document ${key}`); + } + const uri = fields.uri as string; + if (seen.has(uri)) throw new Error(`Invalid MCP search index: duplicate URI ${uri}`); + seen.add(uri); + if (!Array.isArray(fields.sections)) throw new Error('Invalid MCP search index: sections'); + for (const section of fields.sections as unknown[]) validateSection(section, fields.text as string, uri); + } + return searchIndex as unknown as SearchIndex; +} diff --git a/mcp/src/adapters/memory/cursor.ts b/mcp/src/adapters/memory/cursor.ts new file mode 100644 index 0000000..47a5865 --- /dev/null +++ b/mcp/src/adapters/memory/cursor.ts @@ -0,0 +1,43 @@ +// Offset cursors for the in-memory adapters. A cursor carries a fingerprint of the query +// and search index it was issued for, so it cannot be replayed against different results. +import { createHash } from 'node:crypto'; +import { InvalidCursorError } from '../../domain.ts'; + +const CURSOR_VERSION = 1; + +/** Short, stable fingerprint of whatever identifies a result list. */ +export const fingerprint = (value: string): string => createHash('sha256').update(value).digest('hex').slice(0, 8); + +export const encodeCursor = (offset: number, print: string): string => + Buffer.from(JSON.stringify({ v: CURSOR_VERSION, h: print, o: offset })).toString('base64url'); + +/** The offset a cursor points at; 0 when there is none. Throws InvalidCursorError. */ +export function decodeCursor(cursor: string | undefined, print: string): number { + if (!cursor) return 0; + let data: unknown; + try { + data = JSON.parse(Buffer.from(cursor, 'base64url').toString()); + } catch { + throw new InvalidCursorError(); + } + const { v, h, o } = (data ?? {}) as { v?: unknown; h?: unknown; o?: unknown }; + if (v !== CURSOR_VERSION || h !== print || typeof o !== 'number' || !Number.isInteger(o) || o < 0) { + throw new InvalidCursorError(); + } + return o; +} + +/** One page of `items` starting at the cursor, with the cursor for the next page if any. */ +export function paginate( + items: readonly T[], + { cursor, limit }: { cursor?: string | undefined; limit: number }, + print: string, +): { items: T[]; total: number; nextCursor?: string } { + const offset = decodeCursor(cursor, print); + const end = offset + limit; + return { + items: items.slice(offset, end), + total: items.length, + ...(end < items.length ? { nextCursor: encodeCursor(end, print) } : {}), + }; +} diff --git a/mcp/src/adapters/memory/lexical-search.ts b/mcp/src/adapters/memory/lexical-search.ts new file mode 100644 index 0000000..3298720 --- /dev/null +++ b/mcp/src/adapters/memory/lexical-search.ts @@ -0,0 +1,174 @@ +// Keyword search over the in-memory search index: stemmed term frequency, a title boost, and +// fallbacks for compound words ("eventbus" → "event" + "bus") and misspellings. +import type { SearchIndex } from '../../../search-index/schema.ts'; +import { EmptyQueryError } from '../../domain.ts'; +import type { Page, PageRequest, SearchHit, SearchQuery, StoredDocument } from '../../domain.ts'; +import type { SearchBackend } from '../../ports.ts'; +import { fingerprint, paginate } from './cursor.ts'; +import { STOP_WORDS, compactForm, terms, tokenize, stem, withinDistance } from './text.ts'; + +/** A query term and how much a match on it counts: 1 exact, 0.5 fuzzy. */ +type WeightedTerm = readonly [term: string, weight: number]; + +interface DocumentTerms { + document: StoredDocument; + /** Occurrences of each term across title, description, app name, tags and text. */ + frequency: Map; + titleTerms: Set; + sectionTerms: Set[]; +} + +const TITLE_BOOST = 4; +const FUZZY_WEIGHT = 0.5; +const MAX_FUZZY_LENGTH = 32; +const MAX_FUZZY_MATCHES = 3; +const EXCERPT_LEAD = 120; +const EXCERPT_LENGTH = 400; + +/** Edits tolerated for a misspelled word of this length. */ +const fuzzyDistance = (length: number) => (length >= 8 ? 2 : length >= 5 ? 1 : 0); + +const hasTag = (document: StoredDocument, tag: string) => + document.tags.some((candidate) => candidate.toLowerCase() === tag.toLowerCase()); + +function countTerms(words: string[]): Map { + const frequency = new Map(); + for (const word of words) frequency.set(word, (frequency.get(word) ?? 0) + 1); + return frequency; +} + +/** ~400 characters of `content` around the first occurrence of any query term. */ +function excerptFor(content: string, queryTerms: readonly WeightedTerm[]): string { + const lower = content.toLowerCase(); + const first = Math.max(0, queryTerms.map(([term]) => lower.indexOf(term)).find((index) => index >= 0) ?? 0); + const start = Math.max(0, first - EXCERPT_LEAD); + let excerpt = content + .slice(start, start + EXCERPT_LENGTH) + .replace(/\s+/g, ' ') + .trim(); + if (first > EXCERPT_LEAD) excerpt = `…${excerpt}`; + if (content.length > first + EXCERPT_LENGTH - EXCERPT_LEAD) excerpt += '…'; + return excerpt; +} + +export class LexicalSearch implements SearchBackend { + private readonly contentHash: string; + private readonly documents: DocumentTerms[]; + private readonly vocabulary = new Set(); + private readonly vocabularyByLength = new Map(); + + constructor(searchIndex: SearchIndex) { + this.contentHash = searchIndex.contentHash; + const appNames = new Map(searchIndex.apps.map((app) => [app.slug, app.name])); + this.documents = searchIndex.documents.map((document) => { + const fields = [ + document.title, + document.description, + appNames.get(document.app) ?? '', + document.tags.join(' '), + document.text, + ]; + const words = terms(fields.join(' ')); + for (const word of words) this.vocabulary.add(word); + return { + document, + frequency: countTerms(words), + titleTerms: new Set(terms(document.title)), + sectionTerms: document.sections.map((section) => new Set(terms(section.heading))), + }; + }); + for (const term of this.vocabulary) { + const bucket = this.vocabularyByLength.get(term.length); + if (bucket) bucket.push(term); + else this.vocabularyByLength.set(term.length, [term]); + } + } + + async search(query: SearchQuery, page: PageRequest): Promise> { + if (!tokenize(query.text).length) throw new EmptyQueryError(); + // Words the index does not know expand to nothing, which matches nothing: an empty page. + const queryTerms = this.expand(query.text); + const ranked = this.documents + .filter( + ({ document }) => (!query.app || document.app === query.app) && (!query.tag || hasTag(document, query.tag)), + ) + .map((indexed) => this.score(indexed, queryTerms)) + .filter((hit): hit is SearchHit => hit !== null) + .sort((a, b) => b.score - a.score || a.document.uri.localeCompare(b.document.uri)); + const print = fingerprint(`${this.contentHash}|${query.text}|${query.app ?? ''}|${query.tag ?? ''}`); + return paginate(ranked, page, print); + } + + /** + * Query words → index terms. Stop words go unless nothing else is left. A word the index + * lacks is split into two known halves if it can be, else matched fuzzily — except the + * compact form of the whole query, which only exists to catch "EventBus" as one word. + */ + private expand(text: string): WeightedTerm[] { + const tokens = tokenize(text); + const stems = tokens.map(stem); + const meaningful = stems.filter((word) => !STOP_WORDS.has(word)); + const words = meaningful.length ? meaningful : stems; + const compact = tokens.length > 1 && tokens[0] === compactForm(text) ? stems[0] : null; + + const expanded: WeightedTerm[] = []; + for (const word of words) { + if (this.vocabulary.has(word)) { + expanded.push([word, 1]); + continue; + } + const halves = this.split(word); + if (halves) { + expanded.push(...halves.map((half): WeightedTerm => [half, 1])); + } else if (word !== compact && word.length <= MAX_FUZZY_LENGTH) { + expanded.push(...this.fuzzy(word).map((term): WeightedTerm => [term, FUZZY_WEIGHT])); + } + } + return expanded; + } + + /** The first split of `word` into two known terms of at least two letters each. */ + private split(word: string): [string, string] | null { + for (let i = 2; i < word.length - 1; i += 1) { + const head = word.slice(0, i); + const tail = word.slice(i); + if (this.vocabulary.has(head) && this.vocabulary.has(tail)) return [head, tail]; + } + return null; + } + + private fuzzy(word: string): string[] { + const max = fuzzyDistance(word.length); + const matches: string[] = []; + for (let length = word.length - max; length <= word.length + max; length += 1) { + for (const term of this.vocabularyByLength.get(length) ?? []) { + if (withinDistance(word, term, max)) matches.push(term); + } + } + return matches.sort().slice(0, MAX_FUZZY_MATCHES); + } + + private score(indexed: DocumentTerms, queryTerms: readonly WeightedTerm[]): SearchHit | null { + const { document, frequency, titleTerms, sectionTerms } = indexed; + let score = 0; + let hits = 0; + for (const [term, weight] of queryTerms) { + const count = frequency.get(term) ?? 0; + if (!count) continue; + hits += 1; + score += weight * (count + (titleTerms.has(term) ? TITLE_BOOST : 0)); + } + if (!score) return null; + + // The first section whose heading names a query term, else the first section. + const matching = sectionTerms.findIndex((heading) => queryTerms.some(([term]) => heading.has(term))); + const section = document.sections[matching >= 0 ? matching : 0]; + const content = section ? document.text.slice(section.start, section.end) : document.text; + return { + document, + ...(section ? { section: { heading: section.heading, anchor: section.anchor } } : {}), + excerpt: excerptFor(content, queryTerms), + score: Number((score * (1 + (0.5 * hits) / queryTerms.length)).toFixed(4)), + }; + } +} diff --git a/mcp/src/adapters/memory/search-index-repository.ts b/mcp/src/adapters/memory/search-index-repository.ts new file mode 100644 index 0000000..8587ef2 --- /dev/null +++ b/mcp/src/adapters/memory/search-index-repository.ts @@ -0,0 +1,60 @@ +// Documents served straight from the search index file the build generated. +import { readFile } from 'node:fs/promises'; +import { validateSearchIndex } from '../../../search-index/schema.ts'; +import type { SearchIndex } from '../../../search-index/schema.ts'; +import type { App, IndexInfo, DocumentFilter, Page, PageRequest, StoredDocument } from '../../domain.ts'; +import type { DocumentRepository } from '../../ports.ts'; +import { fingerprint, paginate } from './cursor.ts'; + +export async function loadSearchIndex(file: string): Promise { + return validateSearchIndex(JSON.parse(await readFile(file, 'utf8'))); +} + +const matches = (document: StoredDocument, { app, tag, title }: DocumentFilter): boolean => + (!app || document.app === app) && + (!tag || document.tags.some((candidate) => candidate.toLowerCase() === tag.toLowerCase())) && + (!title || document.title.toLowerCase().includes(title.toLowerCase())); + +/** Canonical document order: by app, then route. */ +const byAppThenRoute = (a: StoredDocument, b: StoredDocument) => + a.app.localeCompare(b.app) || a.route.localeCompare(b.route); + +export class SearchIndexRepository implements DocumentRepository { + private readonly searchIndex: SearchIndex; + /** Sorted here rather than trusted: the build sorts, but a search index is just a file. */ + private readonly documents: StoredDocument[]; + private readonly byUri: Map; + private readonly bySlug: Map; + + constructor(searchIndex: SearchIndex) { + this.searchIndex = searchIndex; + this.documents = [...searchIndex.documents].sort(byAppThenRoute); + this.byUri = new Map(searchIndex.documents.map((document) => [document.uri, document])); + this.bySlug = new Map(searchIndex.apps.map((app) => [app.slug, app])); + } + + info(): IndexInfo { + const { generator, indexedAt, contentHash } = this.searchIndex; + return { generator, indexedAt, contentHash }; + } + + apps(): readonly App[] { + return this.searchIndex.apps; + } + + app(slug: string): App | undefined { + return this.bySlug.get(slug); + } + + async get(uri: string): Promise { + return this.byUri.get(uri); + } + + async list(filter: DocumentFilter, page: PageRequest): Promise> { + const items = this.documents.filter((document) => matches(document, filter)); + const print = fingerprint( + `${this.searchIndex.contentHash}|${filter.app ?? ''}|${filter.tag ?? ''}|${filter.title ?? ''}`, + ); + return paginate(items, page, print); + } +} diff --git a/mcp/src/adapters/memory/text.ts b/mcp/src/adapters/memory/text.ts new file mode 100644 index 0000000..f1ce3a7 --- /dev/null +++ b/mcp/src/adapters/memory/text.ts @@ -0,0 +1,71 @@ +// Tokenising, stemming and fuzzy matching for the lexical index. + +/** Words too common to rank on; dropped unless a query has nothing else. */ +export const STOP_WORDS = new Set( + 'a an and are as at be by can create do does for from get how i if in into is it like make me my of on or please the this to use using want what whats when where which who why will with would you your'.split( + ' ', + ), +); + +const splitCamelCase = (value: string) => value.replace(/([a-z\d])([A-Z])/g, '$1 $2'); +const NON_WORD = /[^\p{L}\p{N}]+/gu; + +/** The whole value with every non-letter/digit removed: "Event Bus" → "eventbus". */ +export const compactForm = (value: string): string => value.normalize('NFKC').toLowerCase().replace(NON_WORD, ''); + +/** + * Lower-cased words, camelCase split ("EventBus" → "event", "bus"). Single letters are + * dropped, numbers kept. A multi-word value also yields its compact form first, so + * "EventBus" matches a document that writes "eventbus". + */ +export function tokenize(value: string): string[] { + const original = value.normalize('NFKC'); + const tokens = splitCamelCase(original) + .replace(NON_WORD, ' ') + .trim() + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 1 || /^\d+$/.test(word)); + const compact = compactForm(original); + return compact && !tokens.includes(compact) ? [compact, ...tokens] : tokens; +} + +const DERIVATIONAL = ['ations', 'ation', 'ions', 'ion', 'ings', 'ing', 'ers', 'er']; +const INFLECTIONAL = ['ed', 'es', 's', 'e']; +const MIN_STEM = 4; + +/** A deliberately small suffix stripper; good enough to match "rotate"/"rotation"/"rotating". */ +export function stem(word: string): string { + for (const suffix of DERIVATIONAL) { + if (word.endsWith(suffix) && word.length - suffix.length >= MIN_STEM) return word.slice(0, -suffix.length); + } + if ((word.endsWith('ied') || word.endsWith('ies')) && word.length > 6) return word.slice(0, -3) + 'y'; + for (const suffix of INFLECTIONAL) { + if (suffix === 's' && word.endsWith('ss')) continue; + if (word.endsWith(suffix) && word.length - suffix.length >= MIN_STEM) return word.slice(0, -suffix.length); + } + return word; +} + +export const terms = (value: string): string[] => tokenize(value).map(stem); + +/** Levenshtein distance ≤ max, abandoned as soon as it must exceed `max` (callers only ask about small bounds). */ +export function withinDistance(a: string, b: string, max: number): boolean { + if (Math.abs(a.length - b.length) > max) return false; + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i += 1) { + let diagonal = row[0] ?? 0; + row[0] = i; + let best = i; + for (let j = 1; j <= b.length; j += 1) { + const above = row[j] ?? 0; + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + const value = Math.min(above + 1, (row[j - 1] ?? 0) + 1, diagonal + cost); + row[j] = value; + diagonal = above; + if (value < best) best = value; + } + if (best > max) return false; + } + return (row[b.length] ?? Infinity) <= max; +} diff --git a/mcp/src/compose.ts b/mcp/src/compose.ts new file mode 100644 index 0000000..ceb98c8 --- /dev/null +++ b/mcp/src/compose.ts @@ -0,0 +1,15 @@ +// Wiring: which adapters back the facade. The one place that names concrete backends, +// shared by the server entry point and the tests. +import type { SearchIndex } from '../search-index/schema.ts'; +import { SearchIndexRepository } from './adapters/memory/search-index-repository.ts'; +import { LexicalSearch } from './adapters/memory/lexical-search.ts'; +import { KnowledgeBase } from './knowledge-base.ts'; +import type { KnowledgeBaseOptions } from './knowledge-base.ts'; + +/** A knowledge base served entirely from the search index file, in memory. */ +export function memoryKnowledgeBase(searchIndex: SearchIndex, options: KnowledgeBaseOptions = {}): KnowledgeBase { + return new KnowledgeBase(new SearchIndexRepository(searchIndex), new LexicalSearch(searchIndex), options); +} + +/** The server version: the generator's, e.g. `knowledge-base@1.0.0` → `1.0.0`. */ +export const versionOf = (searchIndex: SearchIndex): string => searchIndex.generator.split('@')[1] ?? '1.0.0'; diff --git a/mcp/src/domain.ts b/mcp/src/domain.ts new file mode 100644 index 0000000..ad66874 --- /dev/null +++ b/mcp/src/domain.ts @@ -0,0 +1,76 @@ +// Types the facade and every search backend share. Protocol-agnostic: nothing here knows +// about MCP, and nothing here knows how documents are stored or searched. +import type { IndexedApp, IndexedDocument } from '../search-index/schema.ts'; + +export type App = IndexedApp; +export type StoredDocument = IndexedDocument; + +export interface PageRequest { + /** Opaque, backend-issued; absent for the first page. */ + cursor?: string | undefined; + limit: number; +} + +export interface Page { + items: T[]; + total: number; + nextCursor?: string; +} + +export interface DocumentFilter { + app?: string | undefined; + /** Case-insensitive exact tag. */ + tag?: string | undefined; + /** Case-insensitive title substring. */ + title?: string | undefined; +} + +export interface SearchQuery { + text: string; + app?: string | undefined; + tag?: string | undefined; +} + +export interface SearchHit { + document: StoredDocument; + /** The section that best matches, when the document has sections. */ + section?: { heading: string; anchor: string | null }; + excerpt: string; + score: number; +} + +/** What the server says about the search index it serves. */ +export interface IndexInfo { + generator: string; + indexedAt: string; + /** Changes whenever any document does; cursors are bound to it. */ + contentHash: string; +} + +/** Errors a caller can fix by changing its input; their message is safe to show. */ +export class UserFacingError extends Error { + override name = 'UserFacingError'; +} + +export class InvalidCursorError extends UserFacingError { + override name = 'InvalidCursorError'; + constructor() { + super('Invalid cursor'); + } +} + +export class EmptyQueryError extends UserFacingError { + override name = 'EmptyQueryError'; + constructor() { + super('Query has no searchable terms'); + } +} + +export class UnknownDocumentError extends UserFacingError { + override name = 'UnknownDocumentError'; + readonly uri: string; + constructor(uri: string) { + super(`Unknown document URI: ${uri}. Use search_documents or list_documents to find valid URIs.`); + this.uri = uri; + } +} diff --git a/mcp/src/knowledge-base.ts b/mcp/src/knowledge-base.ts new file mode 100644 index 0000000..fd1edad --- /dev/null +++ b/mcp/src/knowledge-base.ts @@ -0,0 +1,204 @@ +// The facade the protocol layer talks to. It turns backend results into the public shapes +// clients see (links, app names, bounded reads) and knows nothing about MCP; the backends +// behind it know nothing about clients. +import { UnknownDocumentError } from './domain.ts'; +import type { App, IndexInfo, Page, StoredDocument } from './domain.ts'; +import type { DocumentRepository, SearchBackend } from './ports.ts'; + +export const DEFAULT_SEARCH_LIMIT = 8; +export const DEFAULT_LIST_LIMIT = 50; +export const DEFAULT_READ_CHARS = 20_000; + +export interface PublicDocument { + uri: string; + title: string; + app: string; + appName: string; + route: string; + /** Absolute link, when the server knows its public origin. */ + url?: string; +} + +export interface SearchParams { + query: string; + app?: string | undefined; + tag?: string | undefined; + limit?: number | undefined; + cursor?: string | undefined; +} + +export interface SearchResult extends PublicDocument { + section?: { heading: string; anchor: string | null }; + excerpt: string; + score: number; +} + +export interface ListParams { + app?: string | undefined; + tag?: string | undefined; + title?: string | undefined; + limit?: number | undefined; + cursor?: string | undefined; +} + +export interface ListedDocument extends PublicDocument { + description: string; + tags: string[]; + mimeType: string; + size: number; +} + +export interface ReadParams { + uri: string; + offset?: number | undefined; + maxChars?: number | undefined; +} + +export interface DocumentSlice extends PublicDocument { + mimeType: 'text/markdown'; + offset: number; + totalChars: number; + text: string; + nextOffset?: number; + truncated: boolean; +} + +/** Provenance a client can use to cite or cache a document. */ +export interface DocumentMetadata { + app: string; + appName: string; + route: string; + url?: string; + tags: string[]; + sha256: string; + sourceVersion: string | null; + indexedAt: string; +} + +export interface KnowledgeBaseOptions { + /** `https://host` the site is served from; links are site-relative without it. */ + publicOrigin?: string | undefined; +} + +const isHighSurrogate = (char: string | undefined) => char !== undefined && /[\uD800-\uDBFF]/.test(char); +const isLowSurrogate = (char: string | undefined) => char !== undefined && /[\uDC00-\uDFFF]/.test(char); + +export class KnowledgeBase { + private readonly documents: DocumentRepository; + private readonly searchBackend: SearchBackend; + private readonly publicOrigin: string | undefined; + + constructor(documents: DocumentRepository, searchBackend: SearchBackend, options: KnowledgeBaseOptions = {}) { + this.documents = documents; + this.searchBackend = searchBackend; + this.publicOrigin = options.publicOrigin; + } + + info(): IndexInfo { + return this.documents.info(); + } + + apps(): readonly App[] { + return this.documents.apps(); + } + + /** Absolute when the public origin is known, else the site-relative route. */ + link(document: StoredDocument): string { + return this.publicOrigin ? new URL(document.route, this.publicOrigin).toString() : document.route; + } + + appName(document: StoredDocument): string { + return this.documents.app(document.app)?.name ?? document.app; + } + + async search( + params: SearchParams, + ): Promise<{ query: string; total: number; results: SearchResult[]; nextCursor?: string }> { + const page = await this.searchBackend.search( + { text: params.query, app: params.app, tag: params.tag }, + { cursor: params.cursor, limit: params.limit ?? DEFAULT_SEARCH_LIMIT }, + ); + const results = page.items.map(({ document, section, excerpt, score }) => ({ + ...this.publicDocument(document), + ...(section ? { section } : {}), + excerpt, + score, + })); + return { query: params.query, total: page.total, results, ...this.next(page) }; + } + + async list(params: ListParams = {}): Promise<{ total: number; documents: ListedDocument[]; nextCursor?: string }> { + const page = await this.documents.list( + { app: params.app, tag: params.tag, title: params.title }, + { cursor: params.cursor, limit: params.limit ?? DEFAULT_LIST_LIMIT }, + ); + const documents = page.items.map((document) => ({ + ...this.publicDocument(document), + description: document.description, + tags: document.tags, + mimeType: document.mimeType, + size: document.size, + })); + return { total: page.total, documents, ...this.next(page) }; + } + + /** Every document in canonical order, one page at a time (MCP resources/list). */ + async browse(cursor: string | undefined, limit: number): Promise> { + return this.documents.list({}, { cursor, limit }); + } + + async get(uri: string): Promise { + return this.documents.get(uri); + } + + /** A bounded slice of a document's text that never splits a surrogate pair. */ + async read({ uri, offset = 0, maxChars = DEFAULT_READ_CHARS }: ReadParams): Promise { + const document = await this.documents.get(uri); + if (!document) throw new UnknownDocumentError(uri); + const { text } = document; + let end = Math.min(text.length, offset + maxChars); + if (end < text.length && isHighSurrogate(text[end - 1]) && isLowSurrogate(text[end])) end -= 1; + const nextOffset = end < text.length ? end : undefined; + return { + ...this.publicDocument(document), + mimeType: 'text/markdown', + offset, + totalChars: text.length, + text: text.slice(offset, end), + ...(nextOffset ? { nextOffset } : {}), + truncated: Boolean(nextOffset), + }; + } + + metadata(document: StoredDocument): DocumentMetadata { + return { + app: document.app, + appName: this.appName(document), + route: document.route, + ...this.url(document), + tags: document.tags, + sha256: document.sha256, + sourceVersion: document.sourceVersion, + indexedAt: this.info().indexedAt, + }; + } + + private publicDocument(document: StoredDocument): PublicDocument { + return { + uri: document.uri, + title: document.title, + app: document.app, + appName: this.appName(document), + route: document.route, + ...this.url(document), + }; + } + + private url(document: StoredDocument): { url?: string } { + return this.publicOrigin ? { url: new URL(document.route, this.publicOrigin).toString() } : {}; + } + + private next(page: { nextCursor?: string }): { nextCursor?: string } { + return page.nextCursor ? { nextCursor: page.nextCursor } : {}; + } +} diff --git a/mcp/src/ports.ts b/mcp/src/ports.ts new file mode 100644 index 0000000..8b4d8a0 --- /dev/null +++ b/mcp/src/ports.ts @@ -0,0 +1,34 @@ +// The contracts a storage or search backend implements. The facade (knowledge-base.ts) +// depends on these only; adapters live in ./adapters//. +// +// Every method is async, though the in-memory adapters never wait: a network-bound +// backend (OpenSearch) must fit the same shape. Cursors are opaque and owned by the +// adapter that issued them — an offset for the memory adapter, search_after for OpenSearch. +import type { + App, + IndexInfo, + DocumentFilter, + Page, + PageRequest, + SearchHit, + SearchQuery, + StoredDocument, +} from './domain.ts'; + +export interface DocumentRepository { + info(): IndexInfo; + /** Every app, sorted by slug. */ + apps(): readonly App[]; + app(slug: string): App | undefined; + get(uri: string): Promise; + /** Documents matching `filter`, in canonical (app, route) order. Throws InvalidCursorError. */ + list(filter: DocumentFilter, page: PageRequest): Promise>; +} + +export interface SearchBackend { + /** + * Ranked matches, best first; an empty page when nothing matches. Throws EmptyQueryError + * when the query has no words at all, and InvalidCursorError. + */ + search(query: SearchQuery, page: PageRequest): Promise>; +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..03967b9 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,29 @@ +{ + // The MCP server and its build-time search index generator. Node 24 runs these files as-is + // (type stripping), so the options below only allow syntax Node can erase: no enums, + // namespaces or parameter properties, and relative imports spell their `.ts` extension. + // `tsc -p mcp` type-checks; esbuild bundles for production. + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "noEmit": true, + "allowImportingTsExtensions": true, + // The build side imports the registry helpers in src/utils/ (plain JS, unchecked). + "allowJs": true, + "checkJs": false, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index 51cc2cf..b6bff57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,15 +16,20 @@ }, "devDependencies": { "@astrojs/check": "^0.9.10", + "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.0.1", "astro": "^7.0.4", "chokidar": "^5.0.0", + "eslint": "^10.13.0", "express": "^5.2.1", + "globals": "^17.13.0", "mermaid": "11.4.1", + "prettier": "^3.9.10", "tailwindcss": "^4.0.0", "typescript": "^6.0.3", + "typescript-eslint": "^8.72.0", "web-fragments": "^0.8.2" }, "engines": { @@ -592,6 +597,28 @@ "win32" ] }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@capsizecss/unpack": { "version": "4.0.1", "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", @@ -1185,6 +1212,125 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fontsource-variable/inter": { "version": "5.3.0", "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", @@ -1193,6 +1339,67 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", @@ -1215,6 +1422,18 @@ "mlly": "^1.7.4" } }, + "node_modules/@iconify/utils/node_modules/globals": { + "version": "15.15.0", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", @@ -1761,6 +1980,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mermaid-js/parser": { "version": "0.3.0", "integrity": "sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA==", @@ -2739,6 +2980,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", @@ -2769,6 +3016,12 @@ "@types/unist": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", @@ -2809,6 +3062,225 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.72.0", + "integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/type-utils": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.72.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.13", + "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.0", + "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.0", + "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.0", + "@typescript-eslint/types": "^8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.0", + "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.0", + "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.72.0", + "integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.0", + "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.0", + "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.0", + "@typescript-eslint/tsconfig-utils": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.72.0", + "integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.0", + "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", @@ -2936,6 +3408,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", @@ -3162,6 +3643,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", @@ -3205,6 +3695,18 @@ "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", @@ -3214,6 +3716,19 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", @@ -3468,6 +3983,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crossws": { "version": "0.3.5", "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", @@ -4094,6 +4623,12 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defu": { "version": "6.1.7", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", @@ -4417,12 +4952,193 @@ "dev": true, "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.13.0", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", @@ -4507,6 +5223,18 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", @@ -4563,6 +5291,15 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", @@ -4577,13 +5314,46 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, + "node_modules/flatted": { + "version": "3.4.4", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/flattie": { "version": "1.1.1", "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", @@ -4734,9 +5504,21 @@ "dev": true, "license": "ISC" }, + "node_modules/glob-parent": { + "version": "6.0.2", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { - "version": "15.15.0", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "version": "17.13.0", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -4799,6 +5581,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", @@ -4939,6 +5733,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hookified": { + "version": "1.15.1", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", @@ -5033,6 +5833,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", @@ -5081,6 +5899,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", @@ -5099,6 +5938,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", @@ -5135,6 +5980,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonc-parser": { "version": "3.3.1", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", @@ -5166,6 +6017,15 @@ "node": ">= 12" } }, + "node_modules/keyv": { + "version": "5.6.0", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/khroma": { "version": "2.1.0", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", @@ -5214,6 +6074,19 @@ "dev": true, "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", @@ -5480,6 +6353,21 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", @@ -5730,6 +6618,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mlly": { "version": "1.8.2", "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", @@ -5797,6 +6700,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", @@ -5941,6 +6850,23 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/optionator": { + "version": "0.9.4", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "7.3.1", "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", @@ -5956,6 +6882,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-queue": { "version": "9.3.3", "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", @@ -6022,6 +6990,24 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", @@ -6145,9 +7131,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { - "version": "3.9.6", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.10", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -6201,6 +7196,33 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.3", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", @@ -6566,6 +7588,27 @@ } } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "4.4.1", "integrity": "sha512-rFP+iYKzjLEIqiMiKANhARqiAbk4deDhWnBtnUO/K0D0dPxMGDH4N0FVfBY/VeI+lPrV4wNGCHQZp7EOr7NNBw==", @@ -6866,6 +7909,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-dedent": { "version": "2.3.0", "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", @@ -6882,6 +7937,18 @@ "license": "0BSD", "optional": true }, + "node_modules/type-check": { + "version": "0.4.0", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.1.0", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", @@ -6941,6 +8008,29 @@ "semver": "^7.3.8" } }, + "node_modules/typescript-eslint": { + "version": "8.72.0", + "integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/ufo": { "version": "1.6.4", "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", @@ -7168,6 +8258,15 @@ } } }, + "node_modules/uri-js": { + "version": "4.4.1", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", @@ -7872,6 +8971,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/which": { + "version": "2.0.2", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", @@ -8025,8 +9148,8 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index b2a85d0..c0886bb 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,11 @@ "test:ui": "npx playwright test --ui", "test:headed": "npx playwright test --headed", "test:debug": "npx playwright test --debug", - "test:container": "npx playwright test --config=playwright.config.docker.js" + "test:container": "npx playwright test --config=playwright.config.docker.js", + "test:mcp": "npx playwright test --config=playwright.config.mcp.js", + "typecheck": "tsc -p mcp", + "lint": "npm run typecheck && eslint mcp && prettier --check \"mcp/**/*.ts\" eslint.config.js", + "format": "prettier --write \"mcp/**/*.ts\" eslint.config.js && eslint --fix mcp" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", @@ -36,15 +40,20 @@ }, "devDependencies": { "@astrojs/check": "^0.9.10", + "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.3.2", "@types/node": "^26.0.1", "astro": "^7.0.4", "chokidar": "^5.0.0", + "eslint": "^10.13.0", "express": "^5.2.1", + "globals": "^17.13.0", "mermaid": "11.4.1", + "prettier": "^3.9.10", "tailwindcss": "^4.0.0", "typescript": "^6.0.3", + "typescript-eslint": "^8.72.0", "web-fragments": "^0.8.2" } } diff --git a/playwright.config.mcp.js b/playwright.config.mcp.js new file mode 100644 index 0000000..c337118 --- /dev/null +++ b/playwright.config.mcp.js @@ -0,0 +1,14 @@ +// playwright.config.mcp.js — the MCP suites alone, without the browser harness. +// +// Nothing is built: run `node scripts/setup-test-apps.mjs && npm run build:headless` +// (or `npm test` once) first, so dist/_mcp/search-index.json exists. +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + testMatch: ['mcp-*.spec.js'], + fullyParallel: true, + retries: 0, + reporter: [['list']], + timeout: 30_000, +}); diff --git a/scripts/build-mcp.js b/scripts/build-mcp.js new file mode 100644 index 0000000..eae8ae5 --- /dev/null +++ b/scripts/build-mcp.js @@ -0,0 +1,16 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildSearchIndex, writeSearchIndex } from '../mcp/build/index-builder.ts'; + +/** Generate the runtime search index, after the Astro output exists. */ +export async function buildMcpArtifacts({ root, headless, provenance = [], log = {} }) { + const out = join(root, 'dist', '_mcp'); + mkdirSync(out, { recursive: true }); + + const versions = Object.fromEntries(provenance.flatMap((entry) => entry.slugs.map((slug) => [slug, entry.label]))); + const searchIndex = buildSearchIndex({ root, headless, versions, warn: log.warn ?? (() => {}) }); + writeSearchIndex(join(out, 'search-index.json'), searchIndex); + + log.ok?.(`MCP search index (${searchIndex.documents.length} documents) published → dist/_mcp/`); + return searchIndex; +} diff --git a/scripts/build-vite.js b/scripts/build-vite.js index ea240db..fe4dba9 100644 --- a/scripts/build-vite.js +++ b/scripts/build-vite.js @@ -16,7 +16,7 @@ * Sub-app pages are rendered by src/pages/[...path].astro via getStaticPaths. */ -import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, linkSync, readdirSync, statSync } from 'node:fs'; +import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, readdirSync, statSync } from 'node:fs'; import { join, dirname, resolve, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; @@ -29,6 +29,7 @@ import { PATH_PREFIX, REGISTRY_FILE } from '../src/utils/config.js'; import { layerSubAppCss } from '../src/utils/css-layers.js'; import { rewriteCssUrls } from '../src/utils/transform.js'; import { checkApp, reportFindings } from './check-artifact.js'; +import { buildMcpArtifacts } from './build-mcp.js'; import { ARTIFACT_NAME, MANIFEST, expandManifest, findManifestRoot, isIframe, readManifest, resolveRegistry, sourceKey, stagingName, toRegistryEntry, @@ -197,21 +198,15 @@ function warnOnUnclaimedMembers(root, apps, key) { function fail(msg) { throw new Error(msg); } /** - * Hardlinks a file, copying only if the filesystem will not link it. + * Copies a public asset rather than hardlinking it from apps/. * - * Every sub-app asset is already written twice — once by the tarball extraction - * into apps/{slug}/, once by Astro's static copy of public/ into dist/. The hop - * in between does not need a third set of bytes, and for an artifact carrying - * images or fonts that is the largest of the three. A link is not possible - * across devices (EXDEV) or on a filesystem without hardlink support, hence the - * fallback rather than a bare linkSync. + * The build artifact crosses both Astro's static copier and GitHub Actions' + * artifact archiver. A regular file keeps that path portable: some archivers + * can lose a hardlinked fixture asset when its link peer sits outside the + * archived dist/ tree, which turns a JavaScript request into the HTML fallback. */ -function linkOrCopy(src, dest) { - try { - linkSync(src, dest); - } catch { - copyFileSync(src, dest); - } +function copyAsset(src, dest) { + copyFileSync(src, dest); } async function build() { @@ -407,7 +402,7 @@ async function build() { // the knowledge base's own markup — see src/utils/css-layers.js. writeFileSync(d, layerSubAppCss(rewritten)); } else { - linkOrCopy(s, d); + copyAsset(s, d); } } } @@ -462,6 +457,10 @@ async function build() { ok('Knowledge base CSS (' + blocks[0].length + ' bytes, inlined into every page) published → dist/style.css'); } + // 3b. Build runtime-only MCP assets after Astro has published every page. + step('3b/4 Generating MCP search index → dist/_mcp/'); + await buildMcpArtifacts({ root: ROOT, headless: HEADLESS, provenance, log: { ok, warn, step } }); + // 4. Summary step('4/4 Build complete'); const elapsed = ((Date.now() - startMs) / 1000).toFixed(1); diff --git a/tests/fixtures/mcp/synthetic-index.js b/tests/fixtures/mcp/synthetic-index.js new file mode 100644 index 0000000..1a3f856 --- /dev/null +++ b/tests/fixtures/mcp/synthetic-index.js @@ -0,0 +1,86 @@ +// A hand-built search index that exercises every MCP code path the generated fixture search index +// cannot reach: several apps and tags, anchored sections, a document long enough for +// offset paging, a surrogate pair on a slice boundary, and compound/misspelled terms. +// Deterministic by construction — the golden outputs in ./golden depend on every byte. + +const section = (text, heading, anchor, level) => { + const start = text.indexOf(`${'#'.repeat(level)} ${heading}`); + return { heading, anchor, level, start }; +}; + +function document({ uri, app, title, description, tags, text, headings = [] }) { + const sections = headings.map(([heading, anchor, level]) => section(text, heading, anchor, level)); + sections.forEach((item, i) => { item.end = i + 1 < sections.length ? sections[i + 1].start : text.length; }); + return { + uri, app, route: `/knowledge-base/${uri.slice('kb://'.length)}/`, title, description, tags, + section: null, mimeType: 'text/markdown', sourceVersion: 'v1.2.3', sha256: 'f'.repeat(64), + size: Buffer.byteLength(text), truncated: false, text, sections, + }; +} + +// ~60k characters; the emoji straddles index 20000, which read_document must not split. +const longBody = (() => { + const words = Array.from({ length: 9000 }, (_, i) => `token${i % 97}`).join(' '); + const head = '# Operations handbook\n\nThe operations handbook covers incident response and on-call rotation.\n\n## Incident response\n\n'; + const pad = words.slice(0, 19999 - head.length); + return `${head}${pad}\u{1F600}${words}\n\n## On-call rotation\n\nOn-call engineers rotate weekly and hand over every Monday.`; +})(); + +const documents = [ + document({ + uri: 'kb://eventbus', app: 'eventbus', title: 'Event Bus', tags: ['events'], + description: 'EventBus is the platform for publishing and consuming domain events.', + text: '# Event Bus\n\nEventBus is the platform for publishing and consuming domain events.\n\n## Publishing\n\nPublish events with the producer client and a registered schema.\n\n## Consuming\n\nConsumers subscribe to topics and acknowledge each delivery.', + headings: [['Event Bus', 'event-bus', 1], ['Publishing', 'publishing', 2], ['Consuming', null, 2]], + }), + document({ + uri: 'kb://eventbus/schemas', app: 'eventbus', title: 'Schema registry', tags: ['events', 'reference'], + description: 'Register and evolve event schemas without breaking consumers.', + text: '# Schema registry\n\nRegister and evolve event schemas without breaking consumers.\n\n## Compatibility\n\nBackward compatible changes add optional fields only.', + headings: [['Schema registry', 'schema-registry', 1], ['Compatibility', 'compatibility', 2]], + }), + document({ + uri: 'kb://service-accounts/password-rotation', app: 'service-accounts', title: 'Rotating service account passwords', tags: ['identity'], + description: 'Rotate passwords for service accounts with a staged credential rollout.', + text: '# Rotating service account passwords\n\nRotate passwords for service accounts with a staged credential rollout.\n\n## Prerequisites\n\nYou need the vault operator role.\n\n## Rotation\n\nGenerate the new credential, deploy it, then revoke the old one.', + headings: [['Rotating service account passwords', 'rotating', 1], ['Prerequisites', 'prerequisites', 2], ['Rotation', 'rotation', 2]], + }), + document({ + uri: 'kb://service-accounts/ops/handbook', app: 'service-accounts', title: 'Operations handbook', tags: ['operations'], + description: 'The operations handbook covers incident response and on-call rotation.', + text: longBody, + headings: [['Operations handbook', 'handbook', 1], ['Incident response', 'incident-response', 2], ['On-call rotation', 'on-call', 2]], + }), + document({ + uri: 'kb://agentic-toolkit/skills/aquasec-suppress', app: 'agentic-toolkit', title: 'AquaSec suppress', tags: ['security', 'automation'], + description: 'Generate an AquaSec suppression report from scan findings.', + text: '# AquaSec suppress\n\nGenerate an AquaSec suppression report from scan findings.\n\n## Configuration\n\nInstall the skill and configure report settings.\n\n## Usage\n\nUse the prompt examples to generate and review suppression reports.', + headings: [['AquaSec suppress', 'aquasec-suppress', 1], ['Configuration', 'configuration', 2], ['Usage', 'usage', 2]], + }), + document({ + uri: 'kb://agentic-toolkit/skills/release-notes', app: 'agentic-toolkit', title: 'Release notes writer', tags: ['automation'], + description: 'Draft release notes from merged pull requests and conventional commits.', + text: '# Release notes writer\n\nDraft release notes from merged pull requests and conventional commits.\n\n## Release process\n\nRun the skill after tagging; it groups changes by type.', + headings: [['Release notes writer', 'release-notes', 1], ['Release process', 'release-process', 2]], + }), + document({ + uri: 'kb://external', app: 'external', title: 'External Docs', tags: ['external'], + description: 'Hosted elsewhere.', + text: '# External Docs\n\nHosted elsewhere.\n\nExternal documentation: https://example.com/docs', + }), +]; + +export const syntheticIndex = { + schemaVersion: 1, + generator: 'knowledge-base@1.0.0', + indexedAt: '2026-01-01T00:00:00.000Z', + pathPrefix: 'knowledge-base', + contentHash: 'synthetic-golden-v1', + apps: [ + { slug: 'agentic-toolkit', name: 'Agentic Toolkit', description: 'Automation skills for engineers.', tags: ['automation'], kind: 'artifact', url: null, sourceVersion: 'v1.2.3' }, + { slug: 'eventbus', name: 'Event Bus', description: 'Event platform documentation.', tags: ['events'], kind: 'artifact', url: null, sourceVersion: 'v1.2.3' }, + { slug: 'external', name: 'External Docs', description: 'Hosted elsewhere.', tags: ['external'], kind: 'iframe', url: 'https://example.com/docs', sourceVersion: null }, + { slug: 'service-accounts', name: 'Service Accounts', description: 'Service account operations.', tags: ['identity'], kind: 'artifact', url: null, sourceVersion: 'v1.2.3' }, + ], + documents, +}; diff --git a/tests/mcp-backend-contract.spec.js b/tests/mcp-backend-contract.spec.js new file mode 100644 index 0000000..804bc14 --- /dev/null +++ b/tests/mcp-backend-contract.spec.js @@ -0,0 +1,8 @@ +import { SearchIndexRepository } from '../mcp/src/adapters/memory/search-index-repository.ts'; +import { LexicalSearch } from '../mcp/src/adapters/memory/lexical-search.ts'; +import { describeBackendContract } from './support/backend-contract.js'; + +describeBackendContract('memory', async (searchIndex) => ({ + documents: new SearchIndexRepository(searchIndex), + search: new LexicalSearch(searchIndex), +})); diff --git a/tests/mcp-extract.spec.js b/tests/mcp-extract.spec.js new file mode 100644 index 0000000..b5273cc --- /dev/null +++ b/tests/mcp-extract.spec.js @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import { extractDocument } from '../mcp/build/extract.ts'; + +test('MCP extraction keeps main content and strips untrusted page chrome', () => { + const result = extractDocument(`Ignored

AquaSec suppress

Configure a suppression report from trusted settings.

const safe = true;
diagram
`, { fallbackTitle: 'Fallback' }); + expect(result.title).toBe('AquaSec suppress'); + expect(result.text).toContain('# AquaSec suppress'); + expect(result.text).toContain('```js'); + expect(result.text).toContain('[diagram]'); + expect(result.text).not.toContain('AquaSec decoy'); + expect(result.text).not.toContain('alert(1)'); + expect(result.sections).toEqual([{ heading: 'AquaSec suppress', anchor: 'safe', level: 1, start: 0, end: result.text.length }]); +}); + +test('MCP extraction keeps structure inside wrapper divs and sections', () => { + const result = extractDocument('

Setup

First paragraph.

Second paragraph.

  • One
    • Nested
  • Two
KeyValue
a

b

'); + expect(result.text).toBe('## Setup\n\nFirst paragraph.\n\nSecond paragraph.\n\n- One\n - Nested\n- Two\n\n| Key | Value |\n| a | b |'); + expect(result.sections.map((section) => section.heading)).toEqual(['Setup']); +}); + +test('MCP extraction keeps spaces between inline runs and separates text from headings', () => { + const result = extractDocument('

Setup

Run the tool now.
Then exit.
'); + expect(result.text).toBe('## Setup\n\nRun the tool now.\n\nThen `exit`.'); +}); + +test('MCP extraction keeps code whitespace and highlighter tokens intact', () => { + const result = extractDocument('
root:\n  child: 1\n
foo.bar()
'); + expect(result.text).toBe('```yaml\nroot:\n child: 1\n```\n\n```\nfoo.bar()\n```'); +}); diff --git a/tests/mcp-index-builder.spec.js b/tests/mcp-index-builder.spec.js new file mode 100644 index 0000000..2dcc8cd --- /dev/null +++ b/tests/mcp-index-builder.spec.js @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildSearchIndex } from '../mcp/build/index-builder.ts'; + +test('an iframe entry without name or description still yields a valid search index', () => { + const root = mkdtempSync(join(tmpdir(), 'kb-mcp-index-')); + try { + writeFileSync(join(root, 'apps.json'), JSON.stringify([{ type: 'iframe', slug: 'external', url: 'https://example.com/docs', temporary: true }])); + const searchIndex = buildSearchIndex({ root }); + expect(searchIndex.apps).toEqual([expect.objectContaining({ slug: 'external', name: 'external', description: '' })]); + expect(searchIndex.documents[0].title).toBe('external'); + expect(searchIndex.documents[0].text).toBe('# external\n\nExternal documentation: https://example.com/docs'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/mcp-search.spec.js b/tests/mcp-search.spec.js new file mode 100644 index 0000000..9a3e7b8 --- /dev/null +++ b/tests/mcp-search.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { validateSearchIndex } from '../mcp/search-index/schema.ts'; +import { memoryKnowledgeBase } from '../mcp/src/compose.ts'; + +const searchIndex = validateSearchIndex(JSON.parse(readFileSync(join(process.cwd(), 'dist', '_mcp', 'search-index.json'), 'utf8'))); +const index = memoryKnowledgeBase(searchIndex); +const search = (kb, params) => kb.search(params); +const readDocument = (kb, params) => kb.read(params); + +const document = (uri, app, title, text, tags = []) => ({ + uri, app, route: `/knowledge-base/${uri.slice(5)}/`, title, description: text.slice(0, 120), tags, + mimeType: 'text/markdown', sourceVersion: 'test', sha256: '0'.repeat(64), size: text.length, + truncated: false, text, sections: [{ heading: title, anchor: null, level: 1, start: 0, end: text.length }], +}); +const exampleSearchIndex = { + schemaVersion: 1, + contentHash: 'synthetic-user-examples', + apps: [ + { slug: 'agentic-toolkit', name: 'Agentic Toolkit', description: 'Automation skills.', tags: ['automation'] }, + { slug: 'service-accounts', name: 'Service Accounts', description: 'Service account operations.', tags: ['identity'] }, + { slug: 'eventbus', name: 'Event Bus', description: 'Event platform documentation.', tags: ['events'] }, + ], + documents: [ + document('kb://agentic-toolkit/skills/aquasec-suppress', 'agentic-toolkit', 'AquaSec suppress', '# AquaSec suppress\n\nGenerate an AquaSec suppression report.\n\n## Configuration\n\nInstall skill and configure report settings.\n\n## Use\n\nUse prompt examples to generate and review suppression reports.', ['security']), + document('kb://agentic-toolkit/skills/aquasec-false-positives', 'agentic-toolkit', 'AquaSec false positives', '# AquaSec false positives\n\nReview AquaSec findings without generating a suppression report.', ['security']), + document('kb://service-accounts/password-rotation', 'service-accounts', 'Rotating service account passwords', '# Rotating service account passwords\n\nRotate passwords for service accounts with staged credential rollout.', ['identity']), + document('kb://eventbus', 'eventbus', 'Event Bus', '# Event Bus\n\nEventBus is platform for publishing and consuming domain events.', ['events']), + ], +}; +const exampleIndex = memoryKnowledgeBase(exampleSearchIndex); + +test.describe('MCP generated search index', () => { + test('search finds release process and bounded read returns text', async () => { + const result = await search(index, { query: 'release process' }); + expect(result.results[0].uri).toBe('kb://release-process'); + const read = await readDocument(index, { uri: result.results[0].uri, maxChars: 1000 }); + expect(read.text).toContain('Release Process'); + expect(read.text.length).toBeLessThanOrEqual(1000); + }); + + test('search is deterministic for normalized query terms', async () => { + const query = 'ReleaseProcess'; + expect(await search(index, { query })).toEqual(await search(index, { query })); + }); + + test('ranks all three user-facing natural-language examples', async () => { + const aqua = await search(exampleIndex, { query: 'I would like to create aquasec suppresion report, how do I do that' }); + expect(aqua.results[0].uri).toBe('kb://agentic-toolkit/skills/aquasec-suppress'); + const aquaDocument = exampleSearchIndex.documents.find((entry) => entry.uri === aqua.results[0].uri); + expect(aquaDocument.text).toContain('Configuration'); + expect(aquaDocument.text).toContain('Use'); + + expect((await search(exampleIndex, { query: 'How do I rotate passwords for service accounts?' })).results[0].uri) + .toBe('kb://service-accounts/password-rotation'); + expect((await search(exampleIndex, { query: "What's EventBus?" })).results[0].uri).toBe('kb://eventbus'); + expect((await search(exampleIndex, { query: 'Event Bus' })).results[0].uri).toBe('kb://eventbus'); + }); + + test('a long multi-word query stays cheap against a large vocabulary', async () => { + const words = Array.from({ length: 50000 }, (_, i) => `term${i.toString(36)}x${'y'.repeat(i % 40)}`); + const big = memoryKnowledgeBase({ ...exampleSearchIndex, contentHash: 'big', documents: [...exampleSearchIndex.documents, document('kb://eventbus/vocabulary', 'eventbus', 'Vocabulary', words.join(' '))] }); + const query = 'rotate passwords for service accounts using the staged credential rollout procedure documented somewhere in here please'; + const start = Date.now(); + expect((await search(big, { query })).results[0].uri).toBe('kb://service-accounts/password-rotation'); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/tests/support/backend-contract.js b/tests/support/backend-contract.js new file mode 100644 index 0000000..8508d7e --- /dev/null +++ b/tests/support/backend-contract.js @@ -0,0 +1,130 @@ +// The behaviour every DocumentRepository + SearchBackend pair must show, whatever stores +// the documents. A new backend gets a spec file that calls describeBackendContract() with +// a factory; the memory adapters run it in tests/mcp-backend-contract.spec.js. +// +// Ranking itself is not part of the contract — a vector backend orders results its own way — +// only its invariants are: deterministic, filtered, best first, completely pageable. +import { test, expect } from '@playwright/test'; +import { EmptyQueryError, InvalidCursorError } from '../../mcp/src/domain.ts'; +import { syntheticIndex } from '../fixtures/mcp/synthetic-index.js'; + +/** Every page of a paged call, following cursors; fails if paging does not terminate. */ +async function allPages(fetchPage, limit) { + const items = []; + let cursor; + for (let pages = 0; pages < 100; pages += 1) { + const page = await fetchPage({ cursor, limit }); + expect(page.items.length).toBeLessThanOrEqual(limit); + items.push(...page.items); + if (!page.nextCursor) return { items, total: page.total }; + cursor = page.nextCursor; + } + throw new Error('paging did not terminate'); +} + +const uris = (items) => items.map((item) => (item.document ?? item).uri); + +/** + * @param {string} name + * @param {(search index: object) => Promise<{ documents: import('../../mcp/src/ports.ts').DocumentRepository, + * search: import('../../mcp/src/ports.ts').SearchBackend, close?: () => Promise }>} create + */ +export function describeBackendContract(name, create) { + test.describe(`backend contract: ${name}`, () => { + let backend; + test.beforeAll(async () => { backend = await create(syntheticIndex); }); + test.afterAll(async () => { await backend?.close?.(); }); + + const canonical = [...syntheticIndex.documents] + .sort((a, b) => a.app.localeCompare(b.app) || a.route.localeCompare(b.route)) + .map((document) => document.uri); + + test.describe('DocumentRepository', () => { + test('apps are sorted by slug and resolvable', () => { + const slugs = backend.documents.apps().map((app) => app.slug); + expect(slugs).toEqual([...slugs].sort()); + expect(backend.documents.app('eventbus')?.name).toBe('Event Bus'); + expect(backend.documents.app('missing')).toBeUndefined(); + }); + + test('get returns a known document whole and undefined for an unknown one', async () => { + const document = await backend.documents.get('kb://eventbus/schemas'); + expect(document).toMatchObject({ uri: 'kb://eventbus/schemas', app: 'eventbus', title: 'Schema registry' }); + expect(document.text).toContain('Backward compatible'); + expect(await backend.documents.get('kb://missing')).toBeUndefined(); + }); + + test('list pages through every document once, in canonical order', async () => { + const { items, total } = await allPages((page) => backend.documents.list({}, page), 2); + expect(uris(items)).toEqual(canonical); + expect(total).toBe(canonical.length); + }); + + test('list filters by app, tag (any case) and title substring (any case)', async () => { + const list = async (filter) => uris((await backend.documents.list(filter, { limit: 100 })).items); + expect(await list({ app: 'eventbus' })).toEqual(['kb://eventbus', 'kb://eventbus/schemas']); + expect(await list({ tag: 'REFERENCE' })).toEqual(['kb://eventbus/schemas']); + expect(await list({ title: 'handBOOK' })).toEqual(['kb://service-accounts/ops/handbook']); + expect(await list({ app: 'eventbus', tag: 'identity' })).toEqual([]); + }); + + test('a cursor is rejected for another filter, and garbage is rejected', async () => { + const first = await backend.documents.list({}, { limit: 2 }); + await expect(backend.documents.list({ app: 'eventbus' }, { cursor: first.nextCursor, limit: 2 })).rejects.toThrow(InvalidCursorError); + await expect(backend.documents.list({}, { cursor: 'garbage', limit: 2 })).rejects.toThrow(InvalidCursorError); + }); + }); + + test.describe('SearchBackend', () => { + const search = (query, page = { limit: 25 }) => backend.search.search(query, page); + + test('finds the obvious document first', async () => { + const { items } = await search({ text: 'rotate service account passwords' }); + expect(items[0].document.uri).toBe('kb://service-accounts/password-rotation'); + }); + + test('results are best first, deterministic, and carry an excerpt and a real section', async () => { + const first = await search({ text: 'events schema consumers' }); + expect(first.items.length).toBeGreaterThan(1); + const scores = first.items.map((hit) => hit.score); + expect(scores).toEqual([...scores].sort((a, b) => b - a)); + expect(await search({ text: 'events schema consumers' })).toEqual(first); + for (const hit of first.items) { + expect(hit.excerpt.length).toBeGreaterThan(0); + if (hit.section) expect(hit.document.sections.map((section) => section.heading)).toContain(hit.section.heading); + } + }); + + test('filters by app and tag', async () => { + const byApp = await search({ text: 'release events rotation', app: 'eventbus' }); + expect(byApp.items.every((hit) => hit.document.app === 'eventbus')).toBe(true); + const byTag = await search({ text: 'release events rotation', tag: 'Automation' }); + expect(byTag.items.length).toBeGreaterThan(0); + expect(byTag.items.every((hit) => hit.document.tags.includes('automation'))).toBe(true); + }); + + test('pages through every hit once, in ranked order', async () => { + const query = { text: 'events rotation release report' }; + const whole = await search(query); + const { items, total } = await allPages((page) => search(query, page), 2); + expect(uris(items)).toEqual(uris(whole.items)); + expect(total).toBe(whole.total); + }); + + test('no match is an empty page, not an error', async () => { + expect(await search({ text: 'kubernetes' })).toEqual({ items: [], total: 0 }); + }); + + test('a query with nothing searchable is an EmptyQueryError', async () => { + await expect(search({ text: '!!!' })).rejects.toThrow(EmptyQueryError); + }); + + test('a cursor is rejected for another query, and garbage is rejected', async () => { + const first = await search({ text: 'events rotation release' }, { limit: 1 }); + expect(first.nextCursor).toBeTruthy(); + await expect(search({ text: 'something else' }, { cursor: first.nextCursor, limit: 1 })).rejects.toThrow(InvalidCursorError); + await expect(search({ text: 'events' }, { cursor: 'garbage', limit: 1 })).rejects.toThrow(InvalidCursorError); + }); + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 4b069b0..804a275 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "exclude": ["dist", "tmp", "apps", "public", "tests/fixtures", "actions/node_modules"] + "exclude": ["dist", "tmp", "apps", "public", "tests/fixtures", "actions/node_modules", "mcp"] }