From f3bd4ebe6a0eeb19f39807720f0a7a74b13f8c60 Mon Sep 17 00:00:00 2001 From: Josh Crites Date: Mon, 13 Apr 2026 15:02:39 -0400 Subject: [PATCH 1/4] feat: add DocsGPT semantic search for aztec_search_docs and error lookup Integrate DocsGPT as an optional semantic search backend for documentation queries and error lookup fallback, activated when API_KEY is set. Preserves the existing ripgrep-based search as a fallback when DocsGPT is unavailable or unconfigured, ensuring no public-contract regression for existing callers. - Add DocsGPT HTTP client (src/backends/docsgpt-client.ts) - aztec_search_docs uses semantic search when API_KEY is configured, falls back to ripgrep on DocsGPT errors if local docs are cloned - aztec_lookup_error falls back to semantic doc search when static catalog produces no matches - Schema always advertises section/maxResults for backwards compat; descriptions clarify semantic-mode behavior - maxResults maps to chunks in semantic mode (chunks ?? maxResults ?? 5) Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 12 -- src/backends/docsgpt-client.ts | 100 ++++++++++++ src/index.ts | 284 +++++++++++++++++++++++---------- src/tools/error-lookup.ts | 67 ++++++-- src/tools/index.ts | 2 + src/tools/search.ts | 90 ++++++++--- src/utils/format.ts | 61 ++++++- tests/tools/search.test.ts | 102 ++++++++++-- 8 files changed, 573 insertions(+), 145 deletions(-) create mode 100644 src/backends/docsgpt-client.ts diff --git a/package-lock.json b/package-lock.json index 3de69ee..f9987a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -935,7 +935,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1792,7 +1791,6 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2528,7 +2526,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3118,7 +3115,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4329,7 +4325,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6504,7 +6499,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7404,7 +7398,6 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -8305,7 +8298,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8403,7 +8395,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8550,7 +8541,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8644,7 +8634,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8880,7 +8869,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/backends/docsgpt-client.ts b/src/backends/docsgpt-client.ts new file mode 100644 index 0000000..1df3065 --- /dev/null +++ b/src/backends/docsgpt-client.ts @@ -0,0 +1,100 @@ +/** + * DocsGPT HTTP client for semantic search over Aztec documentation. + * + * Ported from the standalone aztec-docs MCP server. Talks to a DocsGPT + * instance that hosts a vector knowledge base of Aztec developer docs, + * framework source, example contracts, and more. + */ + +export interface SemanticSearchResult { + text: string; + title: string; + source: string; +} + +export class DocsGPTClientError extends Error { + constructor( + message: string, + public statusCode?: number + ) { + super(message); + this.name = "DocsGPTClientError"; + } +} + +export interface DocsGPTClientConfig { + apiUrl: string; + apiKey: string; + timeout?: number; +} + +export class DocsGPTClient { + private baseUrl: string; + private apiKey: string; + private timeout: number; + + constructor(config: DocsGPTClientConfig) { + this.baseUrl = config.apiUrl.replace(/\/+$/, ""); + this.apiKey = config.apiKey; + this.timeout = config.timeout ?? 60_000; + } + + async search( + query: string, + chunks: number = 5 + ): Promise { + const body = { + question: query, + api_key: this.apiKey, + chunks, + }; + + const url = `${this.baseUrl}/api/search`; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.timeout), + }); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new DocsGPTClientError( + `Request timed out after ${this.timeout}ms` + ); + } + throw new DocsGPTClientError( + `Failed to connect to DocsGPT at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (response.status === 401) { + throw new DocsGPTClientError( + "Invalid API key. Get a new key by running /mcp-key in the Noir Discord.", + 401 + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new DocsGPTClientError( + `DocsGPT returned ${response.status}: ${text || response.statusText}`, + response.status + ); + } + + const data = await response.json(); + + if (!Array.isArray(data)) { + return []; + } + + return data.map((item: Record) => ({ + text: String(item.text || ""), + title: String(item.title || ""), + source: String(item.source || ""), + })); + } +} diff --git a/src/index.ts b/src/index.ts index f7553b9..43e2aab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,16 @@ #!/usr/bin/env node /** - * Aztec MCP Server + * Aztec MCP Server (unified) * - * An MCP server that provides local access to Aztec documentation, - * examples, and source code through cloned repositories. + * Provides local access to Aztec documentation, examples, source code, + * and semantic search through cloned repositories and DocsGPT. + * + * Tools: + * aztec_search — Semantic doc search via DocsGPT (requires API_KEY) + * aztec_search_code — Regex code search via ripgrep over cloned repos + * aztec_lookup_error — Error diagnosis with semantic fallback + * aztec_list_examples, aztec_read_example, aztec_read_file — Repo browsing + * aztec_sync_repos, aztec_status — Repo management */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -29,6 +36,7 @@ import { formatSyncResult, formatStatus, formatSearchResults, + formatSemanticSearchResults, formatExamplesList, formatExampleContent, formatFileContent, @@ -38,6 +46,23 @@ import { MCP_VERSION } from "./version.js"; import { getSyncState, writeAutoResyncAttempt } from "./utils/sync-metadata.js"; import { getRepoTag } from "./utils/git.js"; import type { Logger } from "./utils/git.js"; +import { DocsGPTClient } from "./backends/docsgpt-client.js"; + +// --------------------------------------------------------------------------- +// DocsGPT client — optional, enabled when API_KEY is set +// --------------------------------------------------------------------------- + +const docsgptClient = process.env.API_KEY + ? new DocsGPTClient({ + apiUrl: process.env.API_URL || "http://localhost:7091", + apiKey: process.env.API_KEY, + timeout: parseInt(process.env.REQUEST_TIMEOUT || "60000", 10), + }) + : null; + +// --------------------------------------------------------------------------- +// MCP server +// --------------------------------------------------------------------------- const server = new Server( { @@ -53,10 +78,55 @@ const server = new Server( ); /** - * Define available tools + * Define available tools. + * aztec_search_docs description changes based on whether DocsGPT is available. */ -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ +server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = [ + // Documentation search — semantic (DocsGPT) when API_KEY is set, ripgrep fallback otherwise + { + name: "aztec_search_docs", + description: docsgptClient + ? "Search Aztec documentation, guides, patterns, and API reference. " + + "Uses semantic search to find relevant content from developer docs, " + + "Aztec.nr framework docs, example contracts, and more." + : "Search Aztec documentation. Use for finding tutorials, guides, and API documentation.", + inputSchema: { + type: "object" as const, + properties: { + query: { + type: "string", + description: docsgptClient + ? "Natural language search query about Aztec development" + : "Documentation search query", + }, + section: { + type: "string", + description: docsgptClient + ? "Docs section filter (applies to local fallback search only). Examples: tutorials, concepts, developers, reference" + : "Docs section to search. Examples: tutorials, concepts, developers, reference", + }, + maxResults: { + type: "number", + description: docsgptClient + ? "Maximum results to return (default: 5 for semantic search, max: 20)" + : "Maximum results to return (default: 20)", + }, + ...(docsgptClient + ? { + chunks: { + type: "number", + description: + "Number of result chunks for semantic search (default: 5, max: 20). " + + "If omitted, maxResults is used.", + }, + } + : {}), + }, + required: ["query"], + }, + }, + // Repo sync { name: "aztec_sync_repos", description: @@ -64,7 +134,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ "Clones: aztec-packages (docs, aztec-nr, contracts), aztec-examples, aztec-starter. " + "Specify a version to clone a specific Aztec release tag.", inputSchema: { - type: "object", + type: "object" as const, properties: { version: { type: "string", @@ -84,22 +154,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, }, }, + // Status { name: "aztec_status", description: "Check the status of cloned Aztec repositories - shows which repos are available and their commit hashes.", inputSchema: { - type: "object", + type: "object" as const, properties: {}, }, }, + // Code search (ripgrep) { name: "aztec_search_code", description: "Search Aztec contract code and source files. Supports regex patterns. " + "Use for finding function implementations, patterns, and examples.", inputSchema: { - type: "object", + type: "object" as const, properties: { query: { type: "string", @@ -107,7 +179,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, filePattern: { type: "string", - description: "File glob pattern (default: *.nr). Examples: *.ts, *.{nr,ts}", + description: + "File glob pattern (default: *.nr). Examples: *.ts, *.{nr,ts}", }, repo: { type: "string", @@ -122,36 +195,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["query"], }, }, - { - name: "aztec_search_docs", - description: - "Search Aztec documentation. Use for finding tutorials, guides, and API documentation.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Documentation search query", - }, - section: { - type: "string", - description: - "Docs section to search. Examples: tutorials, concepts, developers, reference", - }, - maxResults: { - type: "number", - description: "Maximum results to return (default: 20)", - }, - }, - required: ["query"], - }, - }, + // Examples { name: "aztec_list_examples", description: "List available Aztec contract examples. Returns contract names and paths.", inputSchema: { - type: "object", + type: "object" as const, properties: { category: { type: "string", @@ -166,7 +216,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "Read the source code of an Aztec contract example. Use aztec_list_examples to find available examples.", inputSchema: { - type: "object", + type: "object" as const, properties: { name: { type: "string", @@ -176,12 +226,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["name"], }, }, + // File reading { name: "aztec_read_file", description: "Read any file from the cloned repositories by path. Path should be relative to the repos directory.", inputSchema: { - type: "object", + type: "object" as const, properties: { path: { type: "string", @@ -192,14 +243,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["path"], }, }, + // Error lookup (with semantic fallback) { name: "aztec_lookup_error", description: "Look up an Aztec error by message, error code, or hex signature. " + "Returns root cause and suggested fix. Searches Solidity errors, " + - "TX validation errors, circuit codes, AVM errors, and documentation.", + "TX validation errors, circuit codes, AVM errors, and documentation." + + (docsgptClient + ? " Falls back to semantic documentation search when no exact match is found." + : ""), inputSchema: { - type: "object", + type: "object" as const, properties: { query: { type: "string", @@ -219,41 +274,62 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["query"], }, }, - ], -})); + ]; + + return { tools }; +}); + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- -function validateToolRequest(name: string, args: Record | undefined): void { +function validateToolRequest( + name: string, + args: Record | undefined +): void { switch (name) { case "aztec_sync_repos": case "aztec_status": case "aztec_list_examples": break; - case "aztec_search_code": case "aztec_search_docs": + case "aztec_search_code": case "aztec_lookup_error": - if (!args?.query) throw new McpError(ErrorCode.InvalidParams, "query is required"); + if (!args?.query) + throw new McpError(ErrorCode.InvalidParams, "query is required"); break; case "aztec_read_example": - if (!args?.name) throw new McpError(ErrorCode.InvalidParams, "name is required"); + if (!args?.name) + throw new McpError(ErrorCode.InvalidParams, "name is required"); break; case "aztec_read_file": - if (!args?.path) throw new McpError(ErrorCode.InvalidParams, "path is required"); + if (!args?.path) + throw new McpError(ErrorCode.InvalidParams, "path is required"); break; default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } +// --------------------------------------------------------------------------- +// Auto-resync +// --------------------------------------------------------------------------- + // Sync lock — prevents concurrent syncs from racing over filesystem paths let syncInFlight: Promise | null = null; function createSyncLog(): Logger { - return (message: string, level: "info" | "debug" | "warning" | "error" = "info") => { - server.sendLoggingMessage({ - level, - logger: "aztec-sync", - data: message, - }).catch(() => { }); + return ( + message: string, + level: "info" | "debug" | "warning" | "error" = "info" + ) => { + server + .sendLoggingMessage({ + level, + logger: "aztec-sync", + data: message, + }) + .catch(() => {}); }; } @@ -263,7 +339,10 @@ function ensureAutoResync(): void { if (syncInFlight) return; const syncState = getSyncState(); - if (syncState.kind !== "needsAutoResync" && syncState.kind !== "legacyUnknownVersion") { + if ( + syncState.kind !== "needsAutoResync" && + syncState.kind !== "legacyUnknownVersion" + ) { return; } @@ -279,10 +358,20 @@ function ensureAutoResync(): void { const detectedTag = await getRepoTag("aztec-packages"); if (detectedTag) { version = detectedTag; - log(`Auto-syncing repos (detected ${detectedTag} from existing checkout)...`, "info"); + log( + `Auto-syncing repos (detected ${detectedTag} from existing checkout)...`, + "info" + ); } else { - log("Install predates sync metadata. Run aztec_sync_repos to establish tracked state.", "warning"); - try { writeAutoResyncAttempt("deferred"); } catch { /* non-fatal */ } + log( + "Install predates sync metadata. Run aztec_sync_repos to establish tracked state.", + "warning" + ); + try { + writeAutoResyncAttempt("deferred"); + } catch { + /* non-fatal */ + } return; } } @@ -292,23 +381,33 @@ function ensureAutoResync(): void { log("Auto-sync complete", "info"); } else { // Sync failed or metadata could not be persisted — retry after backoff - try { writeAutoResyncAttempt("retryable"); } catch { /* non-fatal */ } + try { + writeAutoResyncAttempt("retryable"); + } catch { + /* non-fatal */ + } if (syncResult.success) { log(`Auto-resync partial: ${syncResult.message}`, "info"); } else { - log(`Auto-resync failed: ${syncResult.message}. Local tools will use existing checkouts.`, "warning"); + log( + `Auto-resync failed: ${syncResult.message}. Local tools will use existing checkouts.`, + "warning" + ); } } })(); // Fire and forget — auto-resync is best-effort background work. // Read-only tools proceed immediately with existing local checkouts. - syncInFlight = task.finally(() => { syncInFlight = null; }); + syncInFlight = task.finally(() => { + syncInFlight = null; + }); } -/** - * Handle tool calls - */ +// --------------------------------------------------------------------------- +// Tool dispatch +// --------------------------------------------------------------------------- + server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; @@ -316,21 +415,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { validateToolRequest(name, args); // Auto re-sync if MCP server version changed since last sync. - // ensureAutoResync() starts the sync (fire-and-forget) — we then wait for any - // in-flight sync to finish so read-only tools don't race against filesystem mutations. - if (name !== "aztec_sync_repos") { + // When using DocsGPT, aztec_search_docs doesn't need local repos — skip sync wait. + const isSemanticDocsSearch = name === "aztec_search_docs" && docsgptClient != null; + if (name !== "aztec_sync_repos" && !isSemanticDocsSearch) { ensureAutoResync(); - if (syncInFlight) await syncInFlight.catch(() => { }); + if (syncInFlight) await syncInFlight.catch(() => {}); } try { - // validateToolRequest() above guarantees name is a known tool let text!: string; switch (name) { case "aztec_sync_repos": { // Wait for any in-flight sync (auto or manual) before starting - while (syncInFlight) await syncInFlight.catch(() => { }); + while (syncInFlight) await syncInFlight.catch(() => {}); const log = createSyncLog(); const task = syncRepos({ version: args?.version as string | undefined, @@ -338,7 +436,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { repos: args?.repos as string[] | undefined, log, }); - syncInFlight = task.then(() => { }).finally(() => { syncInFlight = null; }); + syncInFlight = task + .then(() => {}) + .finally(() => { + syncInFlight = null; + }); const result = await task; text = formatSyncResult(result); break; @@ -350,6 +452,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } + case "aztec_search_docs": { + const docsResult = await searchAztecDocs( + { + query: args!.query as string, + section: args?.section as string | undefined, + maxResults: args?.maxResults as number | undefined, + chunks: args?.chunks as number | undefined, + }, + docsgptClient + ); + text = + docsResult.kind === "semantic" + ? formatSemanticSearchResults(docsResult.result) + : formatSearchResults(docsResult.result); + break; + } + case "aztec_search_code": { const result = searchAztecCode({ query: args!.query as string, @@ -361,16 +480,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } - case "aztec_search_docs": { - const result = searchAztecDocs({ - query: args!.query as string, - section: args?.section as string | undefined, - maxResults: args?.maxResults as number | undefined, - }); - text = formatSearchResults(result); - break; - } - case "aztec_list_examples": { const result = listAztecExamples({ category: args?.category as string | undefined, @@ -396,15 +505,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "aztec_lookup_error": { - const result = lookupAztecError({ - query: args!.query as string, - category: args?.category as string | undefined, - maxResults: args?.maxResults as number | undefined, - }); + const result = await lookupAztecError( + { + query: args!.query as string, + category: args?.category as string | undefined, + maxResults: args?.maxResults as number | undefined, + }, + docsgptClient + ); text = formatErrorLookupResult(result); break; } - } return { @@ -427,7 +538,8 @@ async function main() { await server.connect(transport); // Log to stderr (stdout is used for MCP communication) - console.error("Aztec MCP Server started"); + const mode = docsgptClient ? "semantic search enabled" : "code search only (set API_KEY for docs)"; + console.error(`Aztec MCP Server started (${mode})`); } main().catch((error) => { diff --git a/src/tools/error-lookup.ts b/src/tools/error-lookup.ts index e56ac31..72fcd1e 100644 --- a/src/tools/error-lookup.ts +++ b/src/tools/error-lookup.ts @@ -1,31 +1,78 @@ /** * Error lookup tool — diagnose any Aztec error by message, code, or hex signature. + * + * Enhanced: when the static catalog + dynamic parsers produce no matches, + * falls back to semantic search via DocsGPT for broader documentation context. */ import { lookupError } from "../utils/error-lookup.js"; import type { ErrorLookupResult } from "../utils/error-lookup.js"; +import type { DocsGPTClient } from "../backends/docsgpt-client.js"; +import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; -export function lookupAztecError(options: { - query: string; - category?: string; - maxResults?: number; -}): { +export interface ErrorLookupToolResult { success: boolean; result: ErrorLookupResult; + semanticResults?: SemanticSearchResult[]; message: string; -} { +} + +export async function lookupAztecError( + options: { + query: string; + category?: string; + maxResults?: number; + }, + docsgptClient?: DocsGPTClient | null +): Promise { const { query, category, maxResults = 10 } = options; const result = lookupError(query, { category, maxResults }); const totalMatches = result.catalogMatches.length + result.codeMatches.length; + // If static lookup found results, return them directly + if (totalMatches > 0) { + return { + success: true, + result, + message: `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"`, + }; + } + + // Semantic fallback: search docs for error context when catalog misses + if (docsgptClient) { + try { + const semanticResults = await docsgptClient.search( + `Aztec error: ${query}`, + 3 + ); + + if (semanticResults.length > 0) { + return { + success: true, + result, + semanticResults, + message: `No exact error match found for "${query}". Showing relevant documentation.`, + }; + } + } catch (err) { + // Surface the DocsGPT failure so callers can distinguish "no docs exist" + // from "the semantic backend is broken/misconfigured". + const detail = err instanceof Error ? err.message : String(err); + return { + success: true, + result, + message: + `No exact error match found for "${query}". ` + + `Semantic documentation search also failed: ${detail}`, + }; + } + } + return { success: true, result, - message: - totalMatches > 0 - ? `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"` - : `No matches found for "${query}". Try a different error message, code, or hex signature.`, + message: `No matches found for "${query}". Try a different error message, code, or hex signature.`, }; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 5b401fe..8200c0c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,5 +9,7 @@ export { listAztecExamples, readAztecExample, readRepoFile, + type DocsSearchResult, + type SemanticSearchToolResult, } from "./search.js"; export { lookupAztecError } from "./error-lookup.js"; diff --git a/src/tools/search.ts b/src/tools/search.ts index dd328eb..68aba02 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -13,6 +13,8 @@ import { } from "../utils/search.js"; import { isRepoCloned } from "../utils/git.js"; import { getRepoNames } from "../repos/config.js"; +import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; +import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; /** * Search Aztec code (contracts, TypeScript, etc.) @@ -60,37 +62,89 @@ export function searchAztecCode(options: { } /** - * Search Aztec documentation + * Semantic search result shape returned by aztec_search_docs when using DocsGPT. */ -export function searchAztecDocs(options: { - query: string; - section?: string; - maxResults?: number; -}): { +export interface SemanticSearchToolResult { success: boolean; - results: SearchResult[]; + results: SemanticSearchResult[]; message: string; -} { +} + +/** + * Result type for aztec_search_docs — either semantic results (DocsGPT) + * or ripgrep code-search results (fallback when no API key). + */ +export type DocsSearchResult = + | { kind: "semantic"; result: SemanticSearchToolResult } + | { kind: "ripgrep"; result: { success: boolean; results: SearchResult[]; message: string } }; + +/** + * Search Aztec documentation. + * + * When a DocsGPT client is available (API_KEY set), uses semantic vector + * search for high-quality natural language results. Otherwise, falls back + * to the ripgrep-based search over cloned markdown files. + */ +export async function searchAztecDocs( + options: { + query: string; + section?: string; + maxResults?: number; + chunks?: number; + }, + client: DocsGPTClient | null +): Promise { + // Semantic path — preferred when DocsGPT is configured + if (client) { + const { query, chunks, maxResults } = options; + const numChunks = Math.min(chunks ?? maxResults ?? 5, 20); + + try { + const results = await client.search(query, numChunks); + + return { + kind: "semantic", + result: { + success: true, + results, + message: + results.length > 0 + ? `Found ${results.length} documentation matches` + : `No documentation matches found for "${query}".`, + }, + }; + } catch { + // DocsGPT unavailable — fall through to ripgrep if local docs exist + } + } + + // Ripgrep fallback — searches cloned markdown files const { query, section, maxResults = 20 } = options; if (!isRepoCloned("aztec-packages-docs")) { return { - success: false, - results: [], - message: - "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation.", + kind: "ripgrep", + result: { + success: false, + results: [], + message: + "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation.", + }, }; } const results = doSearchDocs(query, { section, maxResults }); return { - success: true, - results, - message: - results.length > 0 - ? `Found ${results.length} documentation matches` - : "No documentation matches found", + kind: "ripgrep", + result: { + success: true, + results, + message: + results.length > 0 + ? `Found ${results.length} documentation matches` + : "No documentation matches found", + }, }; } diff --git a/src/utils/format.ts b/src/utils/format.ts index 0ca83ea..89f2a92 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -6,6 +6,8 @@ import { isRepoError, type SyncResult } from "../tools/sync.js"; import type { SearchResult, FileInfo } from "./search.js"; import type { SyncMetadata } from "./sync-metadata.js"; import type { ErrorLookupResult } from "./error-lookup.js"; +import type { SemanticSearchToolResult } from "../tools/search.js"; +import type { ErrorLookupToolResult } from "../tools/error-lookup.js"; export function formatSyncResult(result: SyncResult): string { const lines = [ @@ -153,11 +155,7 @@ export function formatFileContent(result: { return result.content; } -export function formatErrorLookupResult(result: { - success: boolean; - result: ErrorLookupResult; - message: string; -}): string { +export function formatErrorLookupResult(result: ErrorLookupToolResult): string { const lines = [result.message, ""]; const { catalogMatches, codeMatches } = result.result; @@ -193,7 +191,31 @@ export function formatErrorLookupResult(result: { } } - if (catalogMatches.length === 0 && codeMatches.length === 0) { + // Semantic fallback results from DocsGPT + if (result.semanticResults && result.semanticResults.length > 0) { + lines.push("## Related Documentation"); + lines.push(""); + + for (const match of result.semanticResults) { + if (match.title) { + lines.push(`**${match.title}**`); + } + if (match.source) { + lines.push(`Source: ${match.source}`); + } + lines.push(""); + lines.push(match.text); + lines.push(""); + lines.push("---"); + lines.push(""); + } + } + + if ( + catalogMatches.length === 0 && + codeMatches.length === 0 && + (!result.semanticResults || result.semanticResults.length === 0) + ) { lines.push("No matching errors found. Try:"); lines.push("- A numeric error code (e.g., `2002`)"); lines.push("- A hex signature (e.g., `0xa5b2ba17`)"); @@ -202,3 +224,30 @@ export function formatErrorLookupResult(result: { return lines.join("\n"); } + +/** + * Format semantic search results from DocsGPT. + */ +export function formatSemanticSearchResults(result: SemanticSearchToolResult): string { + const lines = [result.message, ""]; + + if (!result.success || result.results.length === 0) { + return lines.join("\n"); + } + + for (const match of result.results) { + if (match.title) { + lines.push(`**${match.title}**`); + } + if (match.source) { + lines.push(`Source: ${match.source}`); + } + lines.push(""); + lines.push(match.text); + lines.push(""); + lines.push("---"); + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/tests/tools/search.test.ts b/tests/tools/search.test.ts index 8895c4a..7689698 100644 --- a/tests/tools/search.test.ts +++ b/tests/tools/search.test.ts @@ -8,6 +8,11 @@ vi.mock("../../src/utils/search.js", () => ({ readFile: vi.fn(), })); +vi.mock("../../src/backends/docsgpt-client.js", () => ({ + DocsGPTClient: vi.fn(), + DocsGPTClientError: class extends Error { constructor(msg: string) { super(msg); this.name = "DocsGPTClientError"; } }, +})); + vi.mock("../../src/utils/git.js", () => ({ isRepoCloned: vi.fn(), })); @@ -18,7 +23,6 @@ vi.mock("../../src/repos/config.js", () => ({ import { searchCode, - searchDocs, listExamples, findExample, readFile, @@ -34,7 +38,6 @@ import { } from "../../src/tools/search.js"; const mockSearchCode = vi.mocked(searchCode); -const mockSearchDocs = vi.mocked(searchDocs); const mockListExamples = vi.mocked(listExamples); const mockFindExample = vi.mocked(findExample); const mockReadFile = vi.mocked(readFile); @@ -97,23 +100,96 @@ describe("searchAztecCode", () => { }); describe("searchAztecDocs", () => { - it("returns failure when aztec-packages-docs not cloned", () => { + it("falls back to ripgrep when no client configured", async () => { mockIsRepoCloned.mockReturnValue(false); - const result = searchAztecDocs({ query: "tutorial" }); - expect(result.success).toBe(false); - expect(result.message).toContain("aztec-packages-docs is not cloned"); + const result = await searchAztecDocs({ query: "tutorial" }, null); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(false); + expect(result.result.message).toContain("aztec-packages-docs is not cloned"); }); - it("delegates to searchDocs with correct options", () => { + it("uses ripgrep when no client and docs are cloned", async () => { mockIsRepoCloned.mockReturnValue(true); - mockSearchDocs.mockReturnValue([]); + const { searchDocs } = await import("../../src/utils/search.js"); + vi.mocked(searchDocs).mockReturnValue([]); - searchAztecDocs({ query: "tutorial", section: "concepts", maxResults: 5 }); + const result = await searchAztecDocs({ query: "tutorial", section: "concepts", maxResults: 5 }, null); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(true); + }); - expect(mockSearchDocs).toHaveBeenCalledWith("tutorial", { - section: "concepts", - maxResults: 5, - }); + it("returns semantic results from DocsGPT client", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([ + { text: "content", title: "Tutorial", source: "docs/tutorial.md" }, + ]), + } as any; + + const result = await searchAztecDocs({ query: "tutorial" }, mockClient); + expect(result.kind).toBe("semantic"); + if (result.kind === "semantic") { + expect(result.result.success).toBe(true); + expect(result.result.results).toHaveLength(1); + expect(result.result.results[0].title).toBe("Tutorial"); + } + expect(mockClient.search).toHaveBeenCalledWith("tutorial", 5); + }); + + it("respects chunks parameter", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", chunks: 10 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 10); + }); + + it("uses maxResults as fallback for chunks in semantic mode", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", maxResults: 8 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 8); + }); + + it("prefers chunks over maxResults when both provided", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", chunks: 3, maxResults: 15 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 3); + }); + + it("falls back to ripgrep when client errors and local docs exist", async () => { + mockIsRepoCloned.mockReturnValue(true); + const { searchDocs } = await import("../../src/utils/search.js"); + vi.mocked(searchDocs).mockReturnValue([ + { file: "docs/tutorial.md", line: 1, content: "tutorial content", repo: "aztec-packages-docs" }, + ]); + + const mockClient = { + search: vi.fn().mockRejectedValue(new Error("network error")), + } as any; + + const result = await searchAztecDocs({ query: "test" }, mockClient); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(true); + expect(result.result.results).toHaveLength(1); + }); + + it("returns ripgrep not-cloned message when client errors and no local docs", async () => { + mockIsRepoCloned.mockReturnValue(false); + + const mockClient = { + search: vi.fn().mockRejectedValue(new Error("network error")), + } as any; + + const result = await searchAztecDocs({ query: "test" }, mockClient); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(false); + expect(result.result.message).toContain("aztec-packages-docs is not cloned"); }); }); From 4827b3358b684ef4bc91c69dbb36076826e19523 Mon Sep 17 00:00:00 2001 From: critesjosh Date: Fri, 1 May 2026 16:33:30 +0000 Subject: [PATCH 2/4] feat: surface DocsGPT errors, version-sync gate, chunks validation, sanitized error-lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses operational footguns and missing safety rails on top of the initial DocsGPT integration in this PR: - Default API_URL → https://aztec.adjacentpossible.dev. The previous http://localhost:7091 default sent the user's API key to whatever was on their loopback port 7091 if API_URL was forgotten, then swallowed the failure as a silent ripgrep fallback so the user never realized semantic search was off. - Stop swallowing DocsGPT errors. searchAztecDocs now returns kind="error" with a clear remediation message when the semantic call fails. New `useLocalFallback?: boolean` opt-in (default false) preserves the previous behavior for callers that want it. When fallback is enabled AND both backends fail, surface BOTH errors so the user sees the full picture. - Version-sync gate. New /api/version endpoint on docsgpt returns the corpus version it indexed; MCP fetches it (POST + redirect:manual for CF-Access compatibility) via DocsGPTClient.getCorpusVersion(), caches per-baseUrl with 5-min positive / 30-s negative TTLs, and compares against the local aztec-packages clone tag (or DEFAULT_AZTEC_VERSION fallback). On mismatch + no override → return kind="version-mismatch" with both versions and remediation. `allowVersionMismatch?: boolean` overrides the gate. On mismatch + useLocalFallback=true → search local docs (which match the local clone version) with an explanatory message. 404/network/shape errors degrade to "unknown" so older or temporarily-broken backends don't permanently block search. - chunks validation. Schema gets minimum:1, maximum:20. Client clamps Math.trunc, rejects non-finite, truncates floats. Backend also clamps for defense-in-depth. - Non-array response shape now throws DocsGPTClientError("Unexpected response shape") instead of silently returning []. Future contract drift surfaces loudly. - lookupAztecError gets a `semanticHealth` field separating "static catalog hit"/"semantic ran"/"semantic failed"/"version mismatch" so callers can distinguish "no docs exist" from "the backend is broken". On semantic failure: 401 → sanitized "/mcp-key" hint, everything else → generic "currently unavailable" — never echoes raw upstream error strings to the user. - Auto-resync skip refined: only skip when useLocalFallback !== true. When the caller has opted into local fallback, we WILL ripgrep cloned docs on a semantic failure, so they need to be fresh. Tests: 241 passing locally. Added tests/utils/version-check.test.ts, tests/tools/error-lookup.test.ts, tests/backends/docsgpt-client.test.ts. Rewrote tests/tools/search.test.ts to cover error reporting, useLocalFallback both with-and-without local docs, version-mismatch + useLocalFallback interaction, version-mismatch + override, and the version-cache "unknown" degradation paths. Companion docsgpt change ships /api/version + /api/search global rerank + URL rewriting in critesjosh/docsgpt-aztec#63. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backends/docsgpt-client.ts | 145 +++++++++++--- src/index.ts | 75 +++++++- src/tools/error-lookup.ts | 118 +++++++++--- src/tools/search.ts | 132 ++++++++++--- src/utils/format.ts | 7 +- src/utils/version-check.ts | 160 +++++++++++++++ tests/backends/docsgpt-client.test.ts | 145 ++++++++++++++ tests/tools/error-lookup.test.ts | 213 ++++++++++++++++++++ tests/tools/search.test.ts | 267 +++++++++++++++++++++----- tests/utils/version-check.test.ts | 158 +++++++++++++++ 10 files changed, 1290 insertions(+), 130 deletions(-) create mode 100644 src/utils/version-check.ts create mode 100644 tests/backends/docsgpt-client.test.ts create mode 100644 tests/tools/error-lookup.test.ts create mode 100644 tests/utils/version-check.test.ts diff --git a/src/backends/docsgpt-client.ts b/src/backends/docsgpt-client.ts index 1df3065..ce0217d 100644 --- a/src/backends/docsgpt-client.ts +++ b/src/backends/docsgpt-client.ts @@ -12,6 +12,14 @@ export interface SemanticSearchResult { source: string; } +export interface CorpusVersionInfo { + /** Tag the backend has indexed, or `"unknown"` when the operator + * hasn't set ``AZTEC_CORPUS_VERSION``. */ + aztec_corpus_version: string; + /** Number of public sources the backend is wired to. Informational. */ + source_count?: number; +} + export class DocsGPTClientError extends Error { constructor( message: string, @@ -28,10 +36,24 @@ export interface DocsGPTClientConfig { timeout?: number; } +// Hard bounds on the `chunks` parameter. Mirrors the docsgpt backend's +// own clamp (see application/api/answer/routes/search.py). Values below +// the minimum become 1 silently; values above the max get clipped to 20. +export const CHUNKS_MIN = 1; +export const CHUNKS_MAX = 20; + +function clampChunks(raw: number): number { + if (!Number.isFinite(raw)) return 5; + const truncated = Math.trunc(raw); + return Math.min(CHUNKS_MAX, Math.max(CHUNKS_MIN, truncated)); +} + export class DocsGPTClient { - private baseUrl: string; private apiKey: string; private timeout: number; + /** Public so callers (e.g. version-check cache key) can identify the + * backend without poking at private state. */ + public readonly baseUrl: string; constructor(config: DocsGPTClientConfig) { this.baseUrl = config.apiUrl.replace(/\/+$/, ""); @@ -46,29 +68,10 @@ export class DocsGPTClient { const body = { question: query, api_key: this.apiKey, - chunks, + chunks: clampChunks(chunks), }; - const url = `${this.baseUrl}/api/search`; - - let response: Response; - try { - response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(this.timeout), - }); - } catch (err) { - if (err instanceof DOMException && err.name === "TimeoutError") { - throw new DocsGPTClientError( - `Request timed out after ${this.timeout}ms` - ); - } - throw new DocsGPTClientError( - `Failed to connect to DocsGPT at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}` - ); - } + const response = await this.request("POST", "/api/search", body); if (response.status === 401) { throw new DocsGPTClientError( @@ -87,8 +90,15 @@ export class DocsGPTClient { const data = await response.json(); + // Strict shape check: a non-array response was previously swallowed + // as `[]`, which masked any future contract drift (e.g. a server + // returning `{results: [...]}` would look like "no matches" forever). + // Throwing here surfaces the mismatch loudly. if (!Array.isArray(data)) { - return []; + throw new DocsGPTClientError( + "Unexpected response shape from /api/search: expected array", + response.status + ); } return data.map((item: Record) => ({ @@ -97,4 +107,93 @@ export class DocsGPTClient { source: String(item.source || ""), })); } + + /** + * Fetch the corpus version this backend is currently serving. + * + * Returns ``null`` only when the endpoint is missing (404) — every + * other failure mode (network, timeout, non-OK status, malformed + * body) throws ``DocsGPTClientError`` so callers can decide whether + * to gate or proceed. Older docsgpt deployments without the + * ``/api/version`` endpoint will 404, in which case the version gate + * treats this as ``"unknown"`` and lets the search proceed (with a + * debug log) — see ``utils/version-check.ts``. + */ + async getCorpusVersion(): Promise { + // POST instead of GET: some auth proxies (Cloudflare Access in + // particular) gate GET routes while letting POST /api/* through + // unauthenticated, so POSTing here matches the same path the + // search endpoint already uses successfully. The docsgpt route + // accepts both verbs — GET is still available for curl diagnostics. + // + // redirect:"manual" is defensive: if POST is gated too (a more + // restrictive proxy), we don't follow into a login HTML page. + const response = await this.request("POST", "/api/version", {}, "manual"); + + if (response.status === 404 || (response.status >= 300 && response.status < 400)) { + return null; + } + + // Some proxies (Cloudflare Access in particular) gate GET routes + // but not POSTs, returning the same 302 with `type: "opaqueredirect"` + // when redirect is manual — that surfaces as status 0 in fetch. + if (response.type === "opaqueredirect" || response.status === 0) { + return null; + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new DocsGPTClientError( + `DocsGPT /api/version returned ${response.status}: ${text || response.statusText}`, + response.status + ); + } + + const data = await response.json(); + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new DocsGPTClientError( + "Unexpected response shape from /api/version: expected object" + ); + } + + const version = (data as Record).aztec_corpus_version; + if (typeof version !== "string") { + throw new DocsGPTClientError( + "Unexpected response shape from /api/version: missing aztec_corpus_version" + ); + } + + const sourceCount = (data as Record).source_count; + return { + aztec_corpus_version: version, + source_count: typeof sourceCount === "number" ? sourceCount : undefined, + }; + } + + private async request( + method: "GET" | "POST", + path: string, + body?: unknown, + redirect: "follow" | "error" | "manual" = "follow" + ): Promise { + const url = `${this.baseUrl}${path}`; + try { + return await fetch(url, { + method, + headers: body != null ? { "Content-Type": "application/json" } : undefined, + body: body != null ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(this.timeout), + redirect, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new DocsGPTClientError( + `Request timed out after ${this.timeout}ms` + ); + } + throw new DocsGPTClientError( + `Failed to connect to DocsGPT at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } } diff --git a/src/index.ts b/src/index.ts index 43e2aab..d570b68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,9 +52,16 @@ import { DocsGPTClient } from "./backends/docsgpt-client.js"; // DocsGPT client — optional, enabled when API_KEY is set // --------------------------------------------------------------------------- +// Default points at the public Aztec DocsGPT deployment so the npm +// package "just works" with only API_KEY set. Override via API_URL for +// self-hosted or local backends. The previous default +// (`http://localhost:7091`) sent the user's API key to whatever was +// listening on their loopback port 7091 if API_URL was forgotten. +const DOCSGPT_DEFAULT_URL = "https://aztec.adjacentpossible.dev"; + const docsgptClient = process.env.API_KEY ? new DocsGPTClient({ - apiUrl: process.env.API_URL || "http://localhost:7091", + apiUrl: process.env.API_URL || DOCSGPT_DEFAULT_URL, apiKey: process.env.API_KEY, timeout: parseInt(process.env.REQUEST_TIMEOUT || "60000", 10), }) @@ -109,16 +116,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { maxResults: { type: "number", description: docsgptClient - ? "Maximum results to return (default: 5 for semantic search, max: 20)" + ? "Maximum results to return (default: 5 for semantic search, 1-20)" : "Maximum results to return (default: 20)", + minimum: 1, + maximum: docsgptClient ? 20 : 100, }, ...(docsgptClient ? { chunks: { type: "number", description: - "Number of result chunks for semantic search (default: 5, max: 20). " + + "Number of result chunks for semantic search (default: 5, 1-20). " + "If omitted, maxResults is used.", + minimum: 1, + maximum: 20, + }, + useLocalFallback: { + type: "boolean", + description: + "If the semantic search backend fails, fall back to ripgrep over local cloned docs. " + + "Default false: failures are surfaced so the user sees backend/auth issues instead of " + + "silently degrading to (potentially stale) local results.", + }, + allowVersionMismatch: { + type: "boolean", + description: + "Override the version-sync gate. By default the search refuses to run when the local " + + "aztec-packages clone tag differs from the corpus version the DocsGPT backend has indexed. " + + "Set true to query anyway (results reflect the corpus version, not your local clone).", }, } : {}), @@ -269,7 +294,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { maxResults: { type: "number", description: "Maximum results to return (default: 10)", + minimum: 1, + maximum: 100, }, + ...(docsgptClient + ? { + allowVersionMismatch: { + type: "boolean", + description: + "Override the version-sync gate for the semantic-fallback documentation search. " + + "Has no effect when the static error catalog already matched.", + }, + } + : {}), }, required: ["query"], }, @@ -415,9 +452,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { validateToolRequest(name, args); // Auto re-sync if MCP server version changed since last sync. - // When using DocsGPT, aztec_search_docs doesn't need local repos — skip sync wait. - const isSemanticDocsSearch = name === "aztec_search_docs" && docsgptClient != null; - if (name !== "aztec_sync_repos" && !isSemanticDocsSearch) { + // For aztec_search_docs with DocsGPT configured AND no local fallback + // requested, we don't need cloned repos — skip the sync wait entirely. + // BUT if the caller passed `useLocalFallback: true`, a semantic + // failure will fall through to ripgrep, so local docs need to be + // fresh — let auto-resync run. + const semanticOnlyDocsSearch = + name === "aztec_search_docs" + && docsgptClient != null + && args?.useLocalFallback !== true; + if (name !== "aztec_sync_repos" && !semanticOnlyDocsSearch) { ensureAutoResync(); if (syncInFlight) await syncInFlight.catch(() => {}); } @@ -459,13 +503,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { section: args?.section as string | undefined, maxResults: args?.maxResults as number | undefined, chunks: args?.chunks as number | undefined, + useLocalFallback: args?.useLocalFallback as boolean | undefined, + allowVersionMismatch: args?.allowVersionMismatch as boolean | undefined, }, docsgptClient ); - text = - docsResult.kind === "semantic" - ? formatSemanticSearchResults(docsResult.result) - : formatSearchResults(docsResult.result); + switch (docsResult.kind) { + case "semantic": + text = formatSemanticSearchResults(docsResult.result); + break; + case "ripgrep": + text = formatSearchResults(docsResult.result); + break; + case "version-mismatch": + case "error": + text = docsResult.message; + break; + } break; } @@ -510,6 +564,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { query: args!.query as string, category: args?.category as string | undefined, maxResults: args?.maxResults as number | undefined, + allowVersionMismatch: args?.allowVersionMismatch as boolean | undefined, }, docsgptClient ); diff --git a/src/tools/error-lookup.ts b/src/tools/error-lookup.ts index 72fcd1e..3b81e25 100644 --- a/src/tools/error-lookup.ts +++ b/src/tools/error-lookup.ts @@ -7,13 +7,29 @@ import { lookupError } from "../utils/error-lookup.js"; import type { ErrorLookupResult } from "../utils/error-lookup.js"; +import { DocsGPTClientError } from "../backends/docsgpt-client.js"; import type { DocsGPTClient } from "../backends/docsgpt-client.js"; import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; +import { checkVersionGate, formatMismatchMessage } from "../utils/version-check.js"; + +export type SemanticHealth = + | "ok" // semantic returned results + | "no_results" // semantic ran cleanly, returned empty + | "skipped" // no client OR static-catalog hit so semantic wasn't tried + | "version_mismatch" // version gate blocked the semantic call + | "failed"; // semantic backend errored export interface ErrorLookupToolResult { + /** Whether the static catalog lookup itself ran. Independent of + * whether the semantic fallback succeeded — see ``semanticHealth`` + * for that signal. */ success: boolean; result: ErrorLookupResult; semanticResults?: SemanticSearchResult[]; + semanticHealth: SemanticHealth; + /** Set when the version gate blocked the semantic call. Surfaced so + * the caller can render the mismatch and the override hint. */ + versionMismatch?: { localVersion: string; corpusVersion: string }; message: string; } @@ -22,57 +38,103 @@ export async function lookupAztecError( query: string; category?: string; maxResults?: number; + /** Opt-in: query the corpus even if its version doesn't match the + * local clone. Default false. Mirrors the same flag on + * ``aztec_search_docs``. */ + allowVersionMismatch?: boolean; }, docsgptClient?: DocsGPTClient | null ): Promise { - const { query, category, maxResults = 10 } = options; + const { query, category, maxResults = 10, allowVersionMismatch = false } = options; const result = lookupError(query, { category, maxResults }); const totalMatches = result.catalogMatches.length + result.codeMatches.length; - // If static lookup found results, return them directly + // Static catalog hit: return immediately, semantic call not needed. if (totalMatches > 0) { return { success: true, result, + semanticHealth: "skipped", message: `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"`, }; } - // Semantic fallback: search docs for error context when catalog misses - if (docsgptClient) { - try { - const semanticResults = await docsgptClient.search( - `Aztec error: ${query}`, - 3 - ); + // No static match. Try semantic fallback if a client exists. + if (!docsgptClient) { + return { + success: true, + result, + semanticHealth: "skipped", + message: `No matches found for "${query}". Try a different error message, code, or hex signature.`, + }; + } - if (semanticResults.length > 0) { - return { - success: true, - result, - semanticResults, - message: `No exact error match found for "${query}". Showing relevant documentation.`, - }; - } - } catch (err) { - // Surface the DocsGPT failure so callers can distinguish "no docs exist" - // from "the semantic backend is broken/misconfigured". - const detail = err instanceof Error ? err.message : String(err); + // Version gate before invoking semantic. Mirrors aztec_search_docs. + if (!allowVersionMismatch) { + const gate = await checkVersionGate(docsgptClient); + if (gate.kind === "mismatch") { return { success: true, result, + semanticHealth: "version_mismatch", + versionMismatch: { localVersion: gate.localVersion, corpusVersion: gate.corpusVersion }, message: - `No exact error match found for "${query}". ` + - `Semantic documentation search also failed: ${detail}`, + `No exact error match found for "${query}", and the semantic fallback was blocked by a version mismatch.\n\n` + + formatMismatchMessage(gate.localVersion, gate.corpusVersion), }; } } - return { - success: true, - result, - message: `No matches found for "${query}". Try a different error message, code, or hex signature.`, - }; + try { + const semanticResults = await docsgptClient.search( + `Aztec error: ${query}`, + 3 + ); + + if (semanticResults.length > 0) { + return { + success: true, + result, + semanticResults, + semanticHealth: "ok", + message: `No exact error match found for "${query}". Showing relevant documentation.`, + }; + } + + return { + success: true, + result, + semanticHealth: "no_results", + message: `No matches found for "${query}". Try a different error message, code, or hex signature.`, + }; + } catch (err) { + // Sanitize: don't echo the raw upstream error string to the user. + // Distinguish auth issues (actionable) from generic failures + // (operational, not the user's problem to fix). The full detail + // lives in stderr logs for the operator. + let userFacing: string; + if (err instanceof DocsGPTClientError && err.statusCode === 401) { + userFacing = + "the API key is invalid (run /mcp-key in the Noir Discord for a new one)"; + } else { + userFacing = "the semantic documentation backend is currently unavailable"; + } + + if (process.env.DEBUG) { + console.error( + `[error-lookup] semantic fallback failed:`, + err instanceof Error ? err.stack ?? err.message : err + ); + } + + return { + success: true, + result, + semanticHealth: "failed", + message: + `No exact error match found for "${query}", and ${userFacing}.`, + }; + } } diff --git a/src/tools/search.ts b/src/tools/search.ts index 68aba02..dba7343 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -15,6 +15,7 @@ import { isRepoCloned } from "../utils/git.js"; import { getRepoNames } from "../repos/config.js"; import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; +import { checkVersionGate, formatMismatchMessage } from "../utils/version-check.js"; /** * Search Aztec code (contracts, TypeScript, etc.) @@ -71,33 +72,83 @@ export interface SemanticSearchToolResult { } /** - * Result type for aztec_search_docs — either semantic results (DocsGPT) - * or ripgrep code-search results (fallback when no API key). + * Result type for aztec_search_docs. + * + * `semantic` — DocsGPT returned results (success path). + * `ripgrep` — local search ran (no API key OR `useLocalFallback: true` + * after a semantic failure). + * `version-mismatch` — local clone vs. corpus version diverge and the + * caller did NOT pass `allowVersionMismatch: true`. The + * caller can re-invoke with the override or sync repos. + * `error` — semantic search failed and either fallback was disabled + * or fallback also failed. `semanticError` always set; + * `fallbackError` set only when both paths failed. */ export type DocsSearchResult = | { kind: "semantic"; result: SemanticSearchToolResult } - | { kind: "ripgrep"; result: { success: boolean; results: SearchResult[]; message: string } }; + | { kind: "ripgrep"; result: { success: boolean; results: SearchResult[]; message: string } } + | { kind: "version-mismatch"; localVersion: string; corpusVersion: string; message: string } + | { kind: "error"; message: string; semanticError: string; fallbackError?: string }; + +interface SearchAztecDocsOptions { + query: string; + section?: string; + maxResults?: number; + chunks?: number; + /** Opt-in: fall back to ripgrep over local cloned docs when DocsGPT + * is unavailable. Default false — silent fallback masks the kind of + * config failures users need to see (wrong API_URL, expired key, + * backend down). */ + useLocalFallback?: boolean; + /** Opt-in: search the corpus even if its version doesn't match the + * local clone. Default false. */ + allowVersionMismatch?: boolean; +} /** * Search Aztec documentation. * * When a DocsGPT client is available (API_KEY set), uses semantic vector - * search for high-quality natural language results. Otherwise, falls back - * to the ripgrep-based search over cloned markdown files. + * search. Errors are surfaced — no silent ripgrep fallback unless the + * caller passes `useLocalFallback: true`. */ export async function searchAztecDocs( - options: { - query: string; - section?: string; - maxResults?: number; - chunks?: number; - }, + options: SearchAztecDocsOptions, client: DocsGPTClient | null ): Promise { - // Semantic path — preferred when DocsGPT is configured + // Semantic path if (client) { - const { query, chunks, maxResults } = options; - const numChunks = Math.min(chunks ?? maxResults ?? 5, 20); + const { query, chunks, maxResults, useLocalFallback = false, allowVersionMismatch = false } = options; + const numChunks = chunks ?? maxResults ?? 5; + + // Version gate. `unknown` results — backend missing /api/version, + // or AZTEC_CORPUS_VERSION unset — let the search proceed (callers + // should not be locked out by an older or under-configured backend). + // + // When `useLocalFallback: true` the caller has explicitly opted + // into "use local docs if semantic is unusable" — a version + // mismatch counts as "unusable" but the local clone is a valid, + // version-aligned alternative, so fall through to ripgrep instead + // of refusing. Without `useLocalFallback`, refuse (it's the gate's + // whole purpose). + if (!allowVersionMismatch) { + const gate = await checkVersionGate(client); + if (gate.kind === "mismatch") { + if (useLocalFallback) { + return ripgrepFallback( + options, + `corpus version is ${gate.corpusVersion} but local clone is ${gate.localVersion}; ` + + `using local docs which match your clone. Pass allowVersionMismatch:true to query the corpus anyway.` + ); + } + return { + kind: "version-mismatch", + localVersion: gate.localVersion, + corpusVersion: gate.corpusVersion, + message: formatMismatchMessage(gate.localVersion, gate.corpusVersion), + }; + } + } try { const results = await client.search(query, numChunks); @@ -113,23 +164,51 @@ export async function searchAztecDocs( : `No documentation matches found for "${query}".`, }, }; - } catch { - // DocsGPT unavailable — fall through to ripgrep if local docs exist + } catch (err) { + const semanticError = err instanceof DocsGPTClientError ? err.message : String(err); + + if (!useLocalFallback) { + return { + kind: "error", + message: + `Semantic documentation search failed: ${semanticError}\n\n` + + `To search local cloned docs instead, retry with \`useLocalFallback: true\`.`, + semanticError, + }; + } + + // useLocalFallback === true: try ripgrep, accumulate both errors + // if it also fails so the user sees the full picture. + return ripgrepFallback(options, semanticError); } } - // Ripgrep fallback — searches cloned markdown files + // No client configured (no API_KEY) — ripgrep is the primary path. + return ripgrepFallback(options, undefined); +} + +function ripgrepFallback( + options: SearchAztecDocsOptions, + semanticError: string | undefined +): DocsSearchResult { const { query, section, maxResults = 20 } = options; if (!isRepoCloned("aztec-packages-docs")) { + const fallbackError = "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation."; + if (semanticError !== undefined) { + return { + kind: "error", + message: + `Both documentation backends are unavailable.\n\n` + + `Semantic search: ${semanticError}\n` + + `Local fallback: ${fallbackError}`, + semanticError, + fallbackError, + }; + } return { kind: "ripgrep", - result: { - success: false, - results: [], - message: - "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation.", - }, + result: { success: false, results: [], message: fallbackError }, }; } @@ -141,9 +220,12 @@ export async function searchAztecDocs( success: true, results, message: - results.length > 0 + (semanticError !== undefined + ? `Semantic search failed (${semanticError}); using local docs.\n` + : "") + + (results.length > 0 ? `Found ${results.length} documentation matches` - : "No documentation matches found", + : "No documentation matches found"), }, }; } diff --git a/src/utils/format.ts b/src/utils/format.ts index 89f2a92..841e448 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -214,7 +214,12 @@ export function formatErrorLookupResult(result: ErrorLookupToolResult): string { if ( catalogMatches.length === 0 && codeMatches.length === 0 && - (!result.semanticResults || result.semanticResults.length === 0) + (!result.semanticResults || result.semanticResults.length === 0) && + // Don't repeat the "try" hints when the message already explains + // *why* there are no semantic results (version mismatch / backend + // failure) — the message field is already descriptive. + result.semanticHealth !== "version_mismatch" && + result.semanticHealth !== "failed" ) { lines.push("No matching errors found. Try:"); lines.push("- A numeric error code (e.g., `2002`)"); diff --git a/src/utils/version-check.ts b/src/utils/version-check.ts new file mode 100644 index 0000000..84023a9 --- /dev/null +++ b/src/utils/version-check.ts @@ -0,0 +1,160 @@ +/** + * Version-sync gate between the MCP server's local aztec-packages + * clone and the DocsGPT backend's indexed corpus. + * + * Why: an MCP user with a v4.1.0 clone querying a v4.2.0 corpus will + * get answers from the wrong version of the docs and not realize it. + * Surfacing the mismatch up front lets them re-sync their clone (or + * intentionally cross-query with `allowVersionMismatch: true`). + * + * Design: + * - Per-process, in-memory cache keyed by base URL. 5-minute positive + * TTL — short enough that an operator rolling out a new corpus + * version sees the new value within minutes; long enough that + * repeated tool calls don't pound `/api/version`. + * - 30-second negative TTL for transient errors (network blips, + * temporary 5xx). Short so a Phase-1-deployed-after-Phase-2 MCP + * starts seeing real version data quickly without operator action. + * - 404 → `"unknown"` cached at the positive TTL, since an older + * docsgpt deployment that doesn't ship the endpoint won't suddenly + * start shipping it within seconds. + */ + +import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; +import { DEFAULT_AZTEC_VERSION } from "../repos/config.js"; +import { getRepoTag } from "./git.js"; + +export type VersionGateResult = + | { kind: "match"; localVersion: string; corpusVersion: string } + | { kind: "mismatch"; localVersion: string; corpusVersion: string } + | { kind: "unknown"; reason: string }; + +interface CachedEntry { + value: string | null; // null = endpoint not available (404) + cachedAt: number; + positive: boolean; +} + +const POSITIVE_TTL_MS = 5 * 60_000; +const NEGATIVE_TTL_MS = 30_000; + +const cache = new Map(); + +/** Test-only: clear the in-memory cache between unit tests. */ +export function _resetVersionCache(): void { + cache.clear(); +} + +/** + * Strip the leading ``v`` and any pre-release suffix. + * ``v4.2.0-aztecnr-rc.2`` → ``4.2.0``. ``v4.2.0`` → ``4.2.0``. + * Used as the equality key. Patch-level differences still compare + * unequal — we deliberately do NOT collapse to major.minor since + * docs/APIs can change at the patch level. + */ +export function normalizeVersion(raw: string | null | undefined): string { + if (!raw) return ""; + let v = raw.trim(); + if (v.startsWith("v")) v = v.slice(1); + const dash = v.indexOf("-"); + if (dash >= 0) v = v.slice(0, dash); + return v; +} + +/** + * Fetch the corpus version, with TTL cache. Returns `null` only when + * the endpoint 404s (older deployment); throws `DocsGPTClientError` for + * other failure modes. + */ +async function fetchCorpusVersionCached(client: DocsGPTClient): Promise { + const key = client.baseUrl; + const now = Date.now(); + const cached = cache.get(key); + if (cached) { + const ttl = cached.positive ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS; + if (now - cached.cachedAt < ttl) { + return cached.value; + } + } + + try { + const info = await client.getCorpusVersion(); + const value = info?.aztec_corpus_version ?? null; + cache.set(key, { value, cachedAt: now, positive: true }); + return value; + } catch (err) { + cache.set(key, { value: null, cachedAt: now, positive: false }); + throw err; + } +} + +/** + * Determine the local aztec-packages version. Falls back to the + * package's `DEFAULT_AZTEC_VERSION` when no clone exists yet, so a + * fresh install can still be gated against the corpus. + */ +export async function getLocalVersion(): Promise { + const tag = await getRepoTag("aztec-packages"); + return tag ?? DEFAULT_AZTEC_VERSION; +} + +/** + * Compute the version gate. Errors fetching `/api/version` resolve to + * ``unknown`` — we never block search on transient backend issues; the + * caller's existing error path handles those when the search itself + * actually fails. + */ +export async function checkVersionGate( + client: DocsGPTClient +): Promise { + const localVersion = await getLocalVersion(); + + let corpusVersion: string | null; + try { + corpusVersion = await fetchCorpusVersionCached(client); + } catch (err) { + const detail = err instanceof DocsGPTClientError ? err.message : String(err); + return { + kind: "unknown", + reason: `could not reach /api/version (${detail})`, + }; + } + + if (corpusVersion === null) { + return { kind: "unknown", reason: "/api/version not implemented by this backend" }; + } + + if (corpusVersion === "unknown" || corpusVersion === "") { + return { kind: "unknown", reason: "backend has no AZTEC_CORPUS_VERSION configured" }; + } + + if (normalizeVersion(localVersion) === normalizeVersion(corpusVersion)) { + return { kind: "match", localVersion, corpusVersion }; + } + + return { kind: "mismatch", localVersion, corpusVersion }; +} + +/** + * Format a user-facing message for a mismatch. Designed to be embedded + * in a tool result so the calling client (Cursor / Claude Desktop / + * etc.) renders it inline. Names both versions and gives concrete + * remediation. + */ +export function formatMismatchMessage( + localVersion: string, + corpusVersion: string +): string { + return [ + `Version mismatch: your MCP server's aztec-packages clone is at ${localVersion},`, + `but the DocsGPT corpus is indexed at ${corpusVersion}.`, + ``, + `Querying across versions can return docs that don't apply to the code on your machine.`, + ``, + `To fix, choose one:`, + ` • Run \`aztec_sync_repos\` with \`version: ${corpusVersion}\` to align your clone`, + ` with the corpus.`, + ` • Pass \`allowVersionMismatch: true\` to this tool call to query anyway`, + ` (results will reflect the corpus version, not your local clone).`, + ].join("\n"); +} diff --git a/tests/backends/docsgpt-client.test.ts b/tests/backends/docsgpt-client.test.ts new file mode 100644 index 0000000..d3e813e --- /dev/null +++ b/tests/backends/docsgpt-client.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { DocsGPTClient, DocsGPTClientError } from "../../src/backends/docsgpt-client.js"; + +const FETCH = "fetch" as keyof typeof globalThis; + +describe("DocsGPTClient.search — chunks clamp", () => { + beforeEach(() => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } }) + ); + }); + afterEach(() => vi.restoreAllMocks()); + + function getBody(): any { + const call = (globalThis.fetch as any).mock.calls[0]; + return JSON.parse(call[1].body); + } + + it("clamps below the minimum (0 → 1)", async () => { + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await c.search("q", 0); + expect(getBody().chunks).toBe(1); + }); + + it("clamps negatives (−5 → 1)", async () => { + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await c.search("q", -5); + expect(getBody().chunks).toBe(1); + }); + + it("clamps above the maximum (100 → 20)", async () => { + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await c.search("q", 100); + expect(getBody().chunks).toBe(20); + }); + + it("truncates non-integers (3.7 → 3)", async () => { + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await c.search("q", 3.7); + expect(getBody().chunks).toBe(3); + }); + + it("falls back to default 5 for non-finite values", async () => { + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await c.search("q", NaN); + expect(getBody().chunks).toBe(5); + }); +}); + +describe("DocsGPTClient.search — response shape", () => { + afterEach(() => vi.restoreAllMocks()); + + it("throws DocsGPTClientError on non-array 200 response (contract drift)", async () => { + // Fresh Response per call — bodies can only be consumed once. + globalThis.fetch = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await expect(c.search("q")).rejects.toBeInstanceOf(DocsGPTClientError); + await expect(c.search("q")).rejects.toThrow(/Unexpected response shape/); + }); + + it("returns parsed results on 200 array response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([{ text: "t", title: "T", source: "s" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + const r = await c.search("q"); + expect(r).toEqual([{ text: "t", title: "T", source: "s" }]); + }); + + it("preserves 401 mapping with a helpful message", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("Unauthorized", { status: 401 }) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + try { + await c.search("q"); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(DocsGPTClientError); + expect((err as DocsGPTClientError).statusCode).toBe(401); + expect((err as DocsGPTClientError).message).toContain("/mcp-key"); + } + }); +}); + +describe("DocsGPTClient.getCorpusVersion", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns the parsed version object on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ aztec_corpus_version: "v4.2.0", source_count: 12 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + const r = await c.getCorpusVersion(); + expect(r).toEqual({ aztec_corpus_version: "v4.2.0", source_count: 12 }); + }); + + it("returns null on 404 (older docsgpt deployment without /api/version)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("Not Found", { status: 404 }) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + expect(await c.getCorpusVersion()).toBeNull(); + }); + + it("throws on malformed body (missing aztec_corpus_version)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ source_count: 12 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await expect(c.getCorpusVersion()).rejects.toThrow(/missing aztec_corpus_version/); + }); + + it("throws on 5xx", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("Internal Server Error", { status: 500 }) + ); + const c = new DocsGPTClient({ apiUrl: "https://x", apiKey: "k" }); + await expect(c.getCorpusVersion()).rejects.toBeInstanceOf(DocsGPTClientError); + }); +}); + +describe("DocsGPTClient — baseUrl", () => { + it("strips trailing slashes and exposes baseUrl publicly", () => { + const c = new DocsGPTClient({ apiUrl: "https://x.example.com///", apiKey: "k" }); + expect(c.baseUrl).toBe("https://x.example.com"); + }); +}); diff --git a/tests/tools/error-lookup.test.ts b/tests/tools/error-lookup.test.ts new file mode 100644 index 0000000..068e4d6 --- /dev/null +++ b/tests/tools/error-lookup.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/utils/error-lookup.js", () => ({ + lookupError: vi.fn(), +})); + +vi.mock("../../src/utils/git.js", () => ({ + isRepoCloned: vi.fn(), + getRepoTag: vi.fn(), +})); + +vi.mock("../../src/repos/config.js", () => ({ + getRepoNames: vi.fn(() => ["aztec-packages"]), + DEFAULT_AZTEC_VERSION: "v4.2.0", +})); + +vi.mock("../../src/backends/docsgpt-client.js", () => ({ + DocsGPTClient: vi.fn(), + DocsGPTClientError: class extends Error { + statusCode?: number; + constructor(msg: string, statusCode?: number) { + super(msg); + this.name = "DocsGPTClientError"; + this.statusCode = statusCode; + } + }, +})); + +import { lookupAztecError } from "../../src/tools/error-lookup.js"; +import { lookupError } from "../../src/utils/error-lookup.js"; +import { getRepoTag } from "../../src/utils/git.js"; +import { DocsGPTClientError } from "../../src/backends/docsgpt-client.js"; +import { _resetVersionCache } from "../../src/utils/version-check.js"; + +const mockLookupError = vi.mocked(lookupError); +const mockGetRepoTag = vi.mocked(getRepoTag); + +function makeClient(overrides: { search?: any; getCorpusVersion?: any } = {}): any { + return { + baseUrl: "https://test.example.com", + search: overrides.search ?? vi.fn().mockResolvedValue([]), + getCorpusVersion: + overrides.getCorpusVersion ?? + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetRepoTag.mockResolvedValue("v4.2.0"); + _resetVersionCache(); + // Default: empty static catalog so we exercise the semantic-fallback path. + mockLookupError.mockReturnValue({ + query: "", + catalogMatches: [], + codeMatches: [], + }); +}); + +describe("lookupAztecError — static catalog hits", () => { + it("returns immediately with semanticHealth='skipped' when catalog matches", async () => { + mockLookupError.mockReturnValue({ + query: "boom", + catalogMatches: [ + { + entry: { + id: "x", + name: "BoomError", + patterns: ["boom"], + cause: "c", + fix: "f", + category: "contract", + source: "s", + }, + matchType: "exact-name", + score: 100, + }, + ], + codeMatches: [], + }); + + const client = makeClient(); + const result = await lookupAztecError({ query: "boom" }, client); + + expect(result.success).toBe(true); + expect(result.semanticHealth).toBe("skipped"); + expect(result.semanticResults).toBeUndefined(); + expect(client.search).not.toHaveBeenCalled(); + expect(client.getCorpusVersion).not.toHaveBeenCalled(); + }); +}); + +describe("lookupAztecError — semantic fallback", () => { + it("calls semantic search when catalog is empty and client present", async () => { + const client = makeClient({ + search: vi.fn().mockResolvedValue([ + { text: "doc", title: "T", source: "S" }, + ]), + }); + + const result = await lookupAztecError({ query: "obscure" }, client); + expect(result.semanticHealth).toBe("ok"); + expect(result.semanticResults).toHaveLength(1); + expect(client.search).toHaveBeenCalledWith("Aztec error: obscure", 3); + }); + + it("returns semanticHealth='no_results' when semantic returns []", async () => { + const client = makeClient({ + search: vi.fn().mockResolvedValue([]), + }); + const result = await lookupAztecError({ query: "obscure" }, client); + expect(result.semanticHealth).toBe("no_results"); + expect(result.semanticResults).toBeUndefined(); + }); + + it("returns semanticHealth='skipped' when no client", async () => { + const result = await lookupAztecError({ query: "obscure" }, null); + expect(result.semanticHealth).toBe("skipped"); + }); +}); + +describe("lookupAztecError — semantic failure (sanitized message)", () => { + it("sets semanticHealth='failed' and returns sanitized message on 401", async () => { + const client = makeClient({ + search: vi.fn().mockRejectedValue(new DocsGPTClientError("Invalid API key.", 401)), + }); + + const result = await lookupAztecError({ query: "x" }, client); + expect(result.semanticHealth).toBe("failed"); + // Sanitized: should mention API key remediation, NOT the verbatim upstream string. + expect(result.message).toContain("API key is invalid"); + expect(result.message).toContain("/mcp-key"); + // Crucially, the raw upstream message must NOT leak (we don't want + // the user to see backend implementation details). + expect(result.message).not.toContain("DocsGPTClientError"); + }); + + it("sets semanticHealth='failed' and uses generic-unavailable message on network error", async () => { + const client = makeClient({ + search: vi.fn().mockRejectedValue(new Error("ECONNREFUSED 127.0.0.1:7091")), + }); + + const result = await lookupAztecError({ query: "x" }, client); + expect(result.semanticHealth).toBe("failed"); + expect(result.message).toContain("currently unavailable"); + expect(result.message).not.toContain("ECONNREFUSED"); + expect(result.message).not.toContain("127.0.0.1"); + }); +}); + +describe("lookupAztecError — version-mismatch gate", () => { + it("blocks semantic fallback when local clone diverges from corpus", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + const client = makeClient({ + search: vi.fn().mockResolvedValue([{ text: "x", title: "x", source: "x" }]), + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await lookupAztecError({ query: "obscure" }, client); + expect(result.semanticHealth).toBe("version_mismatch"); + expect(result.versionMismatch).toEqual({ localVersion: "v4.1.0", corpusVersion: "v4.2.0" }); + expect(client.search).not.toHaveBeenCalled(); + expect(result.message).toContain("version mismatch"); + }); + + it("override: allowVersionMismatch=true bypasses the gate", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + const client = makeClient({ + search: vi.fn().mockResolvedValue([ + { text: "x", title: "x", source: "x" }, + ]), + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await lookupAztecError( + { query: "obscure", allowVersionMismatch: true }, + client + ); + expect(result.semanticHealth).toBe("ok"); + expect(client.search).toHaveBeenCalled(); + }); + + it("does NOT consult the version gate when the static catalog already matched", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + mockLookupError.mockReturnValue({ + query: "boom", + catalogMatches: [ + { + entry: { + id: "x", + name: "BoomError", + patterns: ["boom"], + cause: "c", + fix: "f", + category: "contract", + source: "s", + }, + matchType: "exact-name", + score: 100, + }, + ], + codeMatches: [], + }); + + const client = makeClient({ + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await lookupAztecError({ query: "boom" }, client); + expect(result.semanticHealth).toBe("skipped"); + expect(client.getCorpusVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tools/search.test.ts b/tests/tools/search.test.ts index 7689698..863d80a 100644 --- a/tests/tools/search.test.ts +++ b/tests/tools/search.test.ts @@ -10,15 +10,24 @@ vi.mock("../../src/utils/search.js", () => ({ vi.mock("../../src/backends/docsgpt-client.js", () => ({ DocsGPTClient: vi.fn(), - DocsGPTClientError: class extends Error { constructor(msg: string) { super(msg); this.name = "DocsGPTClientError"; } }, + DocsGPTClientError: class extends Error { + statusCode?: number; + constructor(msg: string, statusCode?: number) { + super(msg); + this.name = "DocsGPTClientError"; + this.statusCode = statusCode; + } + }, })); vi.mock("../../src/utils/git.js", () => ({ isRepoCloned: vi.fn(), + getRepoTag: vi.fn(), })); vi.mock("../../src/repos/config.js", () => ({ getRepoNames: vi.fn(() => ["aztec-packages", "aztec-examples", "noir"]), + DEFAULT_AZTEC_VERSION: "v4.2.0", })); import { @@ -27,7 +36,7 @@ import { findExample, readFile, } from "../../src/utils/search.js"; -import { isRepoCloned } from "../../src/utils/git.js"; +import { isRepoCloned, getRepoTag } from "../../src/utils/git.js"; import { getRepoNames } from "../../src/repos/config.js"; import { searchAztecCode, @@ -36,6 +45,7 @@ import { readAztecExample, readRepoFile, } from "../../src/tools/search.js"; +import { _resetVersionCache } from "../../src/utils/version-check.js"; const mockSearchCode = vi.mocked(searchCode); const mockListExamples = vi.mocked(listExamples); @@ -43,10 +53,33 @@ const mockFindExample = vi.mocked(findExample); const mockReadFile = vi.mocked(readFile); const mockIsRepoCloned = vi.mocked(isRepoCloned); const mockGetRepoNames = vi.mocked(getRepoNames); +const mockGetRepoTag = vi.mocked(getRepoTag); + +/** + * Build a mock DocsGPT client. By default `getCorpusVersion` returns a + * matching version so the version gate passes silently — individual + * tests override it to exercise mismatch / error paths. + */ +function makeClient(overrides: { + search?: any; + getCorpusVersion?: any; + baseUrl?: string; +} = {}): any { + return { + baseUrl: overrides.baseUrl ?? "https://test.example.com", + search: overrides.search ?? vi.fn().mockResolvedValue([]), + getCorpusVersion: + overrides.getCorpusVersion ?? + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0", source_count: 12 }), + }; +} beforeEach(() => { vi.clearAllMocks(); mockGetRepoNames.mockReturnValue(["aztec-packages", "aztec-examples", "noir"]); + // Default: local clone is at the same version the corpus advertises. + mockGetRepoTag.mockResolvedValue("v4.2.0"); + _resetVersionCache(); }); describe("searchAztecCode", () => { @@ -99,13 +132,15 @@ describe("searchAztecCode", () => { }); }); -describe("searchAztecDocs", () => { - it("falls back to ripgrep when no client configured", async () => { +describe("searchAztecDocs — no client (ripgrep-only)", () => { + it("returns ripgrep not-cloned message when no client and no local docs", async () => { mockIsRepoCloned.mockReturnValue(false); const result = await searchAztecDocs({ query: "tutorial" }, null); expect(result.kind).toBe("ripgrep"); - expect(result.result.success).toBe(false); - expect(result.result.message).toContain("aztec-packages-docs is not cloned"); + if (result.kind === "ripgrep") { + expect(result.result.success).toBe(false); + expect(result.result.message).toContain("aztec-packages-docs is not cloned"); + } }); it("uses ripgrep when no client and docs are cloned", async () => { @@ -113,83 +148,229 @@ describe("searchAztecDocs", () => { const { searchDocs } = await import("../../src/utils/search.js"); vi.mocked(searchDocs).mockReturnValue([]); - const result = await searchAztecDocs({ query: "tutorial", section: "concepts", maxResults: 5 }, null); + const result = await searchAztecDocs( + { query: "tutorial", section: "concepts", maxResults: 5 }, + null + ); expect(result.kind).toBe("ripgrep"); - expect(result.result.success).toBe(true); + if (result.kind === "ripgrep") expect(result.result.success).toBe(true); }); +}); +describe("searchAztecDocs — semantic happy path", () => { it("returns semantic results from DocsGPT client", async () => { - const mockClient = { + const client = makeClient({ search: vi.fn().mockResolvedValue([ { text: "content", title: "Tutorial", source: "docs/tutorial.md" }, ]), - } as any; + }); - const result = await searchAztecDocs({ query: "tutorial" }, mockClient); + const result = await searchAztecDocs({ query: "tutorial" }, client); expect(result.kind).toBe("semantic"); if (result.kind === "semantic") { expect(result.result.success).toBe(true); expect(result.result.results).toHaveLength(1); expect(result.result.results[0].title).toBe("Tutorial"); } - expect(mockClient.search).toHaveBeenCalledWith("tutorial", 5); + expect(client.search).toHaveBeenCalledWith("tutorial", 5); }); it("respects chunks parameter", async () => { - const mockClient = { - search: vi.fn().mockResolvedValue([]), - } as any; - - await searchAztecDocs({ query: "test", chunks: 10 }, mockClient); - expect(mockClient.search).toHaveBeenCalledWith("test", 10); + const client = makeClient(); + await searchAztecDocs({ query: "test", chunks: 10 }, client); + expect(client.search).toHaveBeenCalledWith("test", 10); }); it("uses maxResults as fallback for chunks in semantic mode", async () => { - const mockClient = { - search: vi.fn().mockResolvedValue([]), - } as any; - - await searchAztecDocs({ query: "test", maxResults: 8 }, mockClient); - expect(mockClient.search).toHaveBeenCalledWith("test", 8); + const client = makeClient(); + await searchAztecDocs({ query: "test", maxResults: 8 }, client); + expect(client.search).toHaveBeenCalledWith("test", 8); }); it("prefers chunks over maxResults when both provided", async () => { - const mockClient = { - search: vi.fn().mockResolvedValue([]), - } as any; + const client = makeClient(); + await searchAztecDocs({ query: "test", chunks: 3, maxResults: 15 }, client); + expect(client.search).toHaveBeenCalledWith("test", 3); + }); +}); + +describe("searchAztecDocs — error reporting (no silent fallback)", () => { + it("surfaces semantic failure as `error` kind by default", async () => { + mockIsRepoCloned.mockReturnValue(true); + const client = makeClient({ + search: vi.fn().mockRejectedValue(new Error("network error")), + }); - await searchAztecDocs({ query: "test", chunks: 3, maxResults: 15 }, mockClient); - expect(mockClient.search).toHaveBeenCalledWith("test", 3); + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.semanticError).toContain("network error"); + expect(result.message).toContain("Semantic documentation search failed"); + expect(result.message).toContain("useLocalFallback"); + expect(result.fallbackError).toBeUndefined(); + } }); - it("falls back to ripgrep when client errors and local docs exist", async () => { + it("does NOT call the local searchDocs when fallback is disabled (default)", async () => { + mockIsRepoCloned.mockReturnValue(true); + const { searchDocs } = await import("../../src/utils/search.js"); + const client = makeClient({ + search: vi.fn().mockRejectedValue(new Error("boom")), + }); + + await searchAztecDocs({ query: "test" }, client); + expect(vi.mocked(searchDocs)).not.toHaveBeenCalled(); + }); +}); + +describe("searchAztecDocs — useLocalFallback", () => { + it("falls through to ripgrep when client errors AND useLocalFallback=true AND local docs exist", async () => { mockIsRepoCloned.mockReturnValue(true); const { searchDocs } = await import("../../src/utils/search.js"); vi.mocked(searchDocs).mockReturnValue([ { file: "docs/tutorial.md", line: 1, content: "tutorial content", repo: "aztec-packages-docs" }, ]); - const mockClient = { + const client = makeClient({ search: vi.fn().mockRejectedValue(new Error("network error")), - } as any; + }); - const result = await searchAztecDocs({ query: "test" }, mockClient); + const result = await searchAztecDocs( + { query: "test", useLocalFallback: true }, + client + ); expect(result.kind).toBe("ripgrep"); - expect(result.result.success).toBe(true); - expect(result.result.results).toHaveLength(1); + if (result.kind === "ripgrep") { + expect(result.result.success).toBe(true); + expect(result.result.results).toHaveLength(1); + expect(result.result.message).toContain("Semantic search failed"); + } }); - it("returns ripgrep not-cloned message when client errors and no local docs", async () => { - mockIsRepoCloned.mockReturnValue(false); - - const mockClient = { + it("returns compound error when useLocalFallback=true AND both backends fail", async () => { + mockIsRepoCloned.mockReturnValue(false); // no local docs + const client = makeClient({ search: vi.fn().mockRejectedValue(new Error("network error")), - } as any; + }); + + const result = await searchAztecDocs( + { query: "test", useLocalFallback: true }, + client + ); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.semanticError).toContain("network error"); + expect(result.fallbackError).toContain("aztec-packages-docs is not cloned"); + expect(result.message).toContain("Both documentation backends are unavailable"); + } + }); +}); + +describe("searchAztecDocs — version-sync gate", () => { + it("blocks semantic call when local clone is at a different version than the corpus", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + const client = makeClient({ + search: vi.fn().mockResolvedValue([{ text: "x", title: "x", source: "x" }]), + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("version-mismatch"); + if (result.kind === "version-mismatch") { + expect(result.localVersion).toBe("v4.1.0"); + expect(result.corpusVersion).toBe("v4.2.0"); + expect(result.message).toContain("Version mismatch"); + expect(result.message).toContain("allowVersionMismatch"); + } + expect(client.search).not.toHaveBeenCalled(); + }); - const result = await searchAztecDocs({ query: "test" }, mockClient); + it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as matching after normalization", async () => { + mockGetRepoTag.mockResolvedValue("v4.2.0-aztecnr-rc.2"); + const client = makeClient({ + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("semantic"); + }); + + it("on mismatch + useLocalFallback=true, falls through to ripgrep instead of refusing", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + mockIsRepoCloned.mockReturnValue(true); + const { searchDocs } = await import("../../src/utils/search.js"); + vi.mocked(searchDocs).mockReturnValue([ + { file: "docs/x.md", line: 1, content: "local content", repo: "aztec-packages-docs" }, + ]); + + const client = makeClient({ + search: vi.fn().mockResolvedValue([{ text: "x", title: "x", source: "x" }]), + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await searchAztecDocs( + { query: "test", useLocalFallback: true }, + client + ); expect(result.kind).toBe("ripgrep"); - expect(result.result.success).toBe(false); - expect(result.result.message).toContain("aztec-packages-docs is not cloned"); + if (result.kind === "ripgrep") { + expect(result.result.success).toBe(true); + // Message must explain WHY local was used — not just look like a normal local search + expect(result.result.message).toContain("v4.2.0"); + expect(result.result.message).toContain("v4.1.0"); + } + // Crucially, the semantic backend is NOT called when we know it's mismatched + expect(client.search).not.toHaveBeenCalled(); + }); + + it("proceeds when allowVersionMismatch=true even on mismatch", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + const client = makeClient({ + search: vi.fn().mockResolvedValue([ + { text: "x", title: "x", source: "x" }, + ]), + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + }); + + const result = await searchAztecDocs( + { query: "test", allowVersionMismatch: true }, + client + ); + expect(result.kind).toBe("semantic"); + expect(client.search).toHaveBeenCalled(); + }); + + it("treats /api/version 404 (older deployment) as `unknown` and proceeds", async () => { + const client = makeClient({ + // null mimics a 404 response (DocsGPTClient returns null for 404) + getCorpusVersion: vi.fn().mockResolvedValue(null), + search: vi.fn().mockResolvedValue([]), + }); + + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("semantic"); + expect(client.search).toHaveBeenCalled(); + }); + + it("treats /api/version network error as `unknown` and proceeds", async () => { + const client = makeClient({ + getCorpusVersion: vi.fn().mockRejectedValue(new Error("network error")), + search: vi.fn().mockResolvedValue([]), + }); + + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("semantic"); + expect(client.search).toHaveBeenCalled(); + }); + + it("treats `unknown` corpus version (operator hasn't set AZTEC_CORPUS_VERSION) as `unknown`", async () => { + const client = makeClient({ + getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "unknown" }), + search: vi.fn().mockResolvedValue([]), + }); + + const result = await searchAztecDocs({ query: "test" }, client); + expect(result.kind).toBe("semantic"); }); }); diff --git a/tests/utils/version-check.test.ts b/tests/utils/version-check.test.ts new file mode 100644 index 0000000..c32a9dd --- /dev/null +++ b/tests/utils/version-check.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/utils/git.js", () => ({ + getRepoTag: vi.fn(), +})); + +vi.mock("../../src/repos/config.js", () => ({ + DEFAULT_AZTEC_VERSION: "v4.2.0", +})); + +import { + normalizeVersion, + checkVersionGate, + formatMismatchMessage, + getLocalVersion, + _resetVersionCache, +} from "../../src/utils/version-check.js"; +import { getRepoTag } from "../../src/utils/git.js"; + +const mockGetRepoTag = vi.mocked(getRepoTag); + +function makeClient(getCorpusVersion: any, baseUrl = "https://test.example.com"): any { + return { baseUrl, getCorpusVersion }; +} + +beforeEach(() => { + vi.clearAllMocks(); + _resetVersionCache(); +}); + +describe("normalizeVersion", () => { + it("strips leading v", () => { + expect(normalizeVersion("v4.2.0")).toBe("4.2.0"); + }); + + it("strips pre-release suffix", () => { + expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).toBe("4.2.0"); + expect(normalizeVersion("4.2.0-beta")).toBe("4.2.0"); + }); + + it("returns empty string for null/empty", () => { + expect(normalizeVersion(null)).toBe(""); + expect(normalizeVersion("")).toBe(""); + expect(normalizeVersion(undefined)).toBe(""); + }); + + it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as equivalent", () => { + expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).toBe( + normalizeVersion("v4.2.0") + ); + }); + + it("does NOT collapse patch differences (4.2.1 vs 4.2.0)", () => { + expect(normalizeVersion("v4.2.1")).not.toBe(normalizeVersion("v4.2.0")); + }); +}); + +describe("getLocalVersion", () => { + it("returns the cloned tag when available", async () => { + mockGetRepoTag.mockResolvedValue("v4.2.0-aztecnr-rc.2"); + expect(await getLocalVersion()).toBe("v4.2.0-aztecnr-rc.2"); + }); + + it("falls back to DEFAULT_AZTEC_VERSION when no clone", async () => { + mockGetRepoTag.mockResolvedValue(null); + expect(await getLocalVersion()).toBe("v4.2.0"); + }); +}); + +describe("checkVersionGate", () => { + beforeEach(() => { + mockGetRepoTag.mockResolvedValue("v4.2.0"); + }); + + it("returns 'match' when versions normalize equal", async () => { + const client = makeClient( + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }) + ); + const result = await checkVersionGate(client); + expect(result.kind).toBe("match"); + }); + + it("returns 'mismatch' when normalized versions differ", async () => { + mockGetRepoTag.mockResolvedValue("v4.1.0"); + const client = makeClient( + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }) + ); + const result = await checkVersionGate(client); + expect(result.kind).toBe("mismatch"); + if (result.kind === "mismatch") { + expect(result.localVersion).toBe("v4.1.0"); + expect(result.corpusVersion).toBe("v4.2.0"); + } + }); + + it("returns 'unknown' when /api/version 404s (older deployment)", async () => { + const client = makeClient(vi.fn().mockResolvedValue(null)); + const result = await checkVersionGate(client); + expect(result.kind).toBe("unknown"); + }); + + it("returns 'unknown' when corpus reports literal 'unknown'", async () => { + const client = makeClient( + vi.fn().mockResolvedValue({ aztec_corpus_version: "unknown" }) + ); + const result = await checkVersionGate(client); + expect(result.kind).toBe("unknown"); + }); + + it("returns 'unknown' when fetch throws (transient backend failure)", async () => { + const client = makeClient( + vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) + ); + const result = await checkVersionGate(client); + expect(result.kind).toBe("unknown"); + if (result.kind === "unknown") { + expect(result.reason).toContain("could not reach"); + } + }); + + it("caches a successful response and reuses within TTL", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }); + const client = makeClient(fetchSpy); + + await checkVersionGate(client); + await checkVersionGate(client); + await checkVersionGate(client); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("uses a separate cache slot per baseUrl", async () => { + const a = makeClient( + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), + "https://a.example.com" + ); + const b = makeClient( + vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.3.0" }), + "https://b.example.com" + ); + + expect((await checkVersionGate(a)).kind).toBe("match"); + expect((await checkVersionGate(b)).kind).toBe("mismatch"); + // Both clients were called: cache didn't bleed across hosts. + expect(a.getCorpusVersion).toHaveBeenCalledTimes(1); + expect(b.getCorpusVersion).toHaveBeenCalledTimes(1); + }); +}); + +describe("formatMismatchMessage", () => { + it("names both versions and the override flag", () => { + const msg = formatMismatchMessage("v4.1.0", "v4.2.0"); + expect(msg).toContain("v4.1.0"); + expect(msg).toContain("v4.2.0"); + expect(msg).toContain("allowVersionMismatch"); + expect(msg).toContain("aztec_sync_repos"); + }); +}); From 2bb3f5776c2c76fff1b4aa471c85ede111db9ce2 Mon Sep 17 00:00:00 2001 From: critesjosh Date: Fri, 1 May 2026 19:14:32 +0000 Subject: [PATCH 3/4] docs: surface API-key path with Discord invite + recommend-to-user hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: new "API Key (optional, recommended)" section with the Noir Discord invite (https://discord.gg/xMud5StFyA) and step-by-step for obtaining a key via `/mcp-key`. Configuration table now lists every env var (API_KEY, API_URL, REQUEST_TIMEOUT, AZTEC_DEFAULT_VERSION, AZTEC_MCP_REPOS_DIR). Added the missing `aztec_lookup_error` tool section. `aztec_search_docs` parameters expanded to cover the new semantic-only flags (chunks, useLocalFallback, allowVersionMismatch). - Tool descriptions in src/index.ts (the text the LLM consuming the MCP actually reads): when no API_KEY is configured, the descriptions for `aztec_search_docs` and `aztec_lookup_error` now explicitly tell the model that local-only mode is active AND instruct it to suggest the user get a free API key via `/mcp-key` in the Aztec/Noir Discord if a query exceeds what local search can answer. This makes the upgrade path discoverable through normal model use, not just docs. - Startup log when no API_KEY: was "code search only (set API_KEY for docs)" — now names the Discord invite link so an operator running with `--debug` or watching stderr immediately sees the path forward. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++------- src/index.ts | 19 ++++++++++++++--- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8969ff8..6b6820a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,33 @@ # Aztec MCP Server -An MCP (Model Context Protocol) server that provides local access to Aztec documentation, examples, and source code through cloned repositories. +An MCP (Model Context Protocol) server that provides local access to Aztec documentation, examples, and source code through cloned repositories. Optionally augments with **semantic search** over the full Aztec knowledge base when an API key is configured. ## Features - **Version Support**: Clone specific Aztec release tags (e.g., `v4.2.0-aztecnr-rc.2`) - **Local Repository Cloning**: Automatically clones Aztec repositories with sparse checkout for efficiency - **Fast Code Search**: Search Noir contracts and TypeScript files using ripgrep (with fallback) -- **Documentation Search**: Search Aztec documentation by section +- **Documentation Search**: Search Aztec documentation locally; with an API key, semantic vector search across the full corpora (framework docs, examples, Noir stdlib, TypeScript SDK, protocol circuits) +- **Error Lookup**: Static catalog (Solidity / circuit / TX / AVM errors) plus optional semantic fallback for unrecognized errors when an API key is configured - **Example Discovery**: List and read Aztec contract examples +- **Version-sync Gate**: When using the hosted semantic backend, the server detects mismatches between your local clone tag and the indexed corpus and refuses to query across versions unless explicitly overridden + +## API Key (optional, recommended) + +The MCP server runs in two modes: + +| Mode | How to enable | What you get | +| -------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Local (default)** | No setup | Ripgrep search over cloned markdown + code; static error catalog | +| **Semantic (recommended)** | Set `API_KEY` env var | Vector search over all 12 indexed Aztec corpora (developer docs, network docs, Aztec.nr, examples, aztec.js, CLI, TypeScript API, e2e tests, protocol circuits, L1 contracts, Noir docs, Noir stdlib); semantic error fallback; version-sync gate | + +### Getting a key + +1. Join the Aztec/Noir Discord: +2. Run `/mcp-key` in any channel — the bot DMs you a personal API key (UUID) ephemerally. +3. Paste the key into your MCP client config under `env.API_KEY` (see [Configuration](#configuration)). + +Keys are free, persistent (re-running `/mcp-key` returns the same key), and revocable via `/forget-me`. ## Installation @@ -29,19 +48,30 @@ aztec-mcp ### Claude Code Plugin -Add to your `.mcp.json`: +Add to your `.mcp.json`. The minimal config is just the command; add `env.API_KEY` to enable semantic search. ```json { "mcpServers": { "aztec-mcp": { "command": "npx", - "args": ["-y", "@aztec/mcp-server@latest"] + "args": ["-y", "@aztec/mcp-server@latest"], + "env": { + "API_KEY": "" + } } } } ``` +| Env var | Default | Purpose | +| --------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| `API_KEY` | unset | Personal API key from `/mcp-key` in the Noir Discord (). Unset → local-only mode. | +| `API_URL` | `https://aztec.adjacentpossible.dev` | DocsGPT backend the semantic search hits. Override to point at a self-hosted instance. | +| `REQUEST_TIMEOUT` | `60000` | Semantic-search request timeout (ms). | +| `AZTEC_DEFAULT_VERSION` | `v4.2.0-aztecnr-rc.2` | Default version tag for `aztec_sync_repos`. | +| `AZTEC_MCP_REPOS_DIR` | `~/.aztec-mcp/repos/` | Where local clones live. | + ## Available Tools ### `aztec_sync_repos` @@ -90,13 +120,16 @@ aztec_search_code({ query: "PrivateSet", filePattern: "*.nr" }) ### `aztec_search_docs` -Search Aztec documentation. +Search Aztec documentation. Local ripgrep by default; semantic vector search when `API_KEY` is set. **Parameters:** - `query` (string, required): Documentation search query -- `section` (string): Docs section (tutorials, concepts, developers, reference) -- `maxResults` (number): Maximum results (default: 20) +- `section` (string): Docs section, applies to local search only (tutorials, concepts, developers, reference) +- `maxResults` (number): Maximum results (default: 20 local; 5 semantic, max 20) +- `chunks` (number, semantic only): Number of result chunks (1-20). If omitted, `maxResults` is used. +- `useLocalFallback` (boolean, semantic only): If the semantic backend fails, fall back to local ripgrep. Default `false` so backend errors surface clearly. +- `allowVersionMismatch` (boolean, semantic only): Override the version-sync gate. Default `false`. The gate refuses to search when your local `aztec-packages` clone tag differs from the corpus version the backend has indexed. ### `aztec_list_examples` @@ -122,6 +155,17 @@ Read any file from cloned repositories. - `path` (string, required): File path relative to repos directory +### `aztec_lookup_error` + +Diagnose any Aztec error by message, error code, or hex signature. Returns root cause and suggested fix from a static catalog covering Solidity errors, TX validation errors, circuit codes, AVM errors, and operator FAQ. With `API_KEY` set, falls back to semantic documentation search when the static catalog has no hit. + +**Parameters:** + +- `query` (string, required): Error message, numeric code (e.g., `2002`), or hex signature (e.g., `0xa5b2ba17`) +- `category` (string): Filter (`contract`, `circuit`, `tx-validation`, `l1`, `avm`, `sequencer`, `operator`, `general`) +- `maxResults` (number): Default 10 +- `allowVersionMismatch` (boolean, semantic only): Override the version-sync gate for the semantic fallback. Has no effect when the static catalog already matched. + ## Configuration Options ### Storage Location diff --git a/src/index.ts b/src/index.ts index d570b68..d8dc570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ? "Search Aztec documentation, guides, patterns, and API reference. " + "Uses semantic search to find relevant content from developer docs, " + "Aztec.nr framework docs, example contracts, and more." - : "Search Aztec documentation. Use for finding tutorials, guides, and API documentation.", + : "Search Aztec documentation in local ripgrep-only mode. Use for tutorials, guides, " + + "and API documentation that's already cloned. " + + "IMPORTANT: this server is running WITHOUT an API_KEY, which means no semantic " + + "search across the full Aztec corpora (Aztec.nr framework, Noir stdlib, contract " + + "examples, TypeScript SDK, protocol circuits, etc.). If a query needs material " + + "this local search can't find, suggest the user get a free API key by running " + + "`/mcp-key` in the Aztec/Noir Discord (https://discord.gg/xMud5StFyA) and setting " + + "API_KEY in their MCP client config.", inputSchema: { type: "object" as const, properties: { @@ -277,7 +284,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { "TX validation errors, circuit codes, AVM errors, and documentation." + (docsgptClient ? " Falls back to semantic documentation search when no exact match is found." - : ""), + : " NOTE: this server is running without an API_KEY, so unrecognized errors " + + "won't get a semantic-documentation fallback. Suggest the user get a free " + + "API key via `/mcp-key` in the Aztec/Noir Discord " + + "(https://discord.gg/xMud5StFyA) if their query is for an error not in the " + + "static catalog."), inputSchema: { type: "object" as const, properties: { @@ -593,7 +604,9 @@ async function main() { await server.connect(transport); // Log to stderr (stdout is used for MCP communication) - const mode = docsgptClient ? "semantic search enabled" : "code search only (set API_KEY for docs)"; + const mode = docsgptClient + ? "semantic search enabled" + : "local-only mode — set API_KEY to enable semantic search (free key via /mcp-key in https://discord.gg/xMud5StFyA)"; console.error(`Aztec MCP Server started (${mode})`); } From 7ecdbeec5d8858ae56950b4566cabefb8761600d Mon Sep 17 00:00:00 2001 From: critesjosh Date: Fri, 1 May 2026 19:50:38 +0000 Subject: [PATCH 4/4] feat: surface upgrade-path guidance via server `instructions` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is invisible to LLMs consuming the MCP — the only surfaces the model sees are tool descriptions, parameter docs, and the server-level `instructions` field returned in InitializeResult. Tool descriptions cover per-call hints (added in the previous commit), but they're only seen when the model decides to call that specific tool. The `instructions` field is read once per session as system context by Claude Desktop, Cursor, and other MCP-aware clients — better placement for the persistent "what is this server + how do I get more" message. Two variants: - LOCAL_ONLY_INSTRUCTIONS (no API_KEY): tells the model the server is in degraded mode and explicitly directs it to recommend `/mcp-key` in the Aztec/Noir Discord (https://discord.gg/xMud5StFyA) when a user query exceeds what local search can answer. - SEMANTIC_INSTRUCTIONS (API_KEY set): tells the model semantic search is enabled, lists the 12 indexed corpora, and explains the version-sync gate behavior (so it can guide the user through a `version-mismatch` result intelligently — sync repos vs. pass `allowVersionMismatch: true`). Verified end-to-end via stdio: a fresh `initialize` request returns the matching instructions string in result.instructions for both modes, including the Discord invite link in local-only mode and absent from semantic mode (no nag once configured). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/index.ts b/src/index.ts index d8dc570..71f72f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,43 @@ const docsgptClient = process.env.API_KEY // MCP server // --------------------------------------------------------------------------- +// Server-level `instructions` are returned in the InitializeResult and +// forwarded to the LLM as session context by Claude Desktop / Cursor / +// other MCP-aware clients. It's the right place for the "what this +// server is + how to upgrade it" message — read once per session, +// no tool call needed. +const SEMANTIC_INSTRUCTIONS = + "This Aztec MCP server provides documentation, source code, contract " + + "examples, and error diagnosis for the Aztec Protocol stack. Semantic " + + "vector search is ENABLED across the full indexed corpora (developer " + + "docs, network docs, Aztec.nr framework, contract examples, aztec.js " + + "SDK, CLI, TypeScript API, Noir stdlib, Noir language docs, protocol " + + "circuits, L1 contracts, e2e tests). Prefer `aztec_search_docs` for " + + "natural-language documentation queries; `aztec_search_code` for " + + "regex-based code search; `aztec_lookup_error` for error diagnosis. " + + "The server enforces a version-sync gate between the user's local " + + "aztec-packages clone tag and the corpus version it queries — if the " + + "user gets a `version-mismatch` result, they can either run " + + "`aztec_sync_repos` with the corpus version, or pass " + + "`allowVersionMismatch: true` to query across versions intentionally."; + +const LOCAL_ONLY_INSTRUCTIONS = + "This Aztec MCP server is running in LOCAL-ONLY mode (no API_KEY " + + "configured). It can answer questions from cloned aztec-packages, " + + "aztec-examples, and aztec-starter repositories using ripgrep search " + + "and a static error catalog. It does NOT have semantic search across " + + "the full Aztec corpora (Aztec.nr framework, Noir stdlib, contract " + + "examples, TypeScript SDK, protocol circuits, etc.) and does NOT have " + + "a semantic-documentation fallback for unknown errors. " + + "IMPORTANT: a free API key unlocks all of that. If a user asks a " + + "question that the local tools can't answer well — anything about " + + "specific Aztec.nr identifiers, framework patterns, error codes not " + + "in the static catalog, or requires versioned/curated documentation " + + "— recommend they run `/mcp-key` in the Aztec/Noir Discord " + + "(https://discord.gg/xMud5StFyA) to get a personal API key, then set " + + "API_KEY in their MCP client config (e.g. .mcp.json, Claude Desktop " + + "config, etc.) and restart the server."; + const server = new Server( { name: "aztec-mcp", @@ -81,6 +118,7 @@ const server = new Server( tools: {}, logging: {}, }, + instructions: docsgptClient ? SEMANTIC_INSTRUCTIONS : LOCAL_ONLY_INSTRUCTIONS, } );