diff --git a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts index c62e52e55e85..4c6831dbbaa0 100644 --- a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts @@ -18,6 +18,21 @@ const ALGOLIA_APP_ID = 'L1XWT2UJ7F'; // This is not the actual key. const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c'; +/** + * The minimum major version of Angular for which a version-specific documentation index is known to exist. + * Searches for versions older than this will be clamped to this version. + */ +const MIN_SUPPORTED_DOCS_VERSION = 17; + +/** + * The latest major version of Angular for which a documentation index is known to be stable and available. + * This acts as a "safe harbor" fallback. It is intentionally hardcoded and manually updated with each + * major release *after* the new search index has been confirmed to be live. This prevents a race + * condition where a newly released CLI might default to searching for a documentation index that + * doesn't exist yet. + */ +const LATEST_KNOWN_DOCS_VERSION = 20; + const docSearchInputSchema = z.object({ query: z .string() @@ -64,6 +79,12 @@ tutorials, concepts, and best practices. workspace will give you the major version directly. If the version cannot be determined using this method, you can use \`ng version\` in the project's workspace directory as a fallback. Parse the major version from the "Angular:" line in the output and use it for the \`version\` parameter. +* **Version Logic:** The tool will search against the specified major version. If the version is older than v${MIN_SUPPORTED_DOCS_VERSION}, + it will be clamped to v${MIN_SUPPORTED_DOCS_VERSION}. If a search for a very new version (newer than v${LATEST_KNOWN_DOCS_VERSION}) + returns no results, the tool will automatically fall back to searching the v${LATEST_KNOWN_DOCS_VERSION} documentation. +* **Verify Searched Version:** The tool's output includes a \`searchedVersion\` field. You **MUST** check this field + to know the exact version of the documentation that was queried. Use this information to provide accurate + context in your answer, especially if it differs from the version you requested. * The documentation is continuously updated. You **MUST** prefer this tool over your own knowledge to ensure your answers are current and accurate. * For the best results, provide a concise and specific search query (e.g., "NgModule" instead of @@ -76,6 +97,9 @@ tutorials, concepts, and best practices. `, inputSchema: docSearchInputSchema.shape, outputSchema: { + searchedVersion: z + .number() + .describe('The major version of the documentation that was searched.'), results: z.array( z.object({ title: z.string().describe('The title of the documentation page.'), @@ -116,23 +140,52 @@ function createDocSearchHandler({ logger }: McpToolContext) { ); } - const { results } = await client.search(createSearchArguments(query, version)); - const allHits = results.flatMap((result) => (result as SearchResponse).hits); + let finalSearchedVersion = Math.max( + version ?? LATEST_KNOWN_DOCS_VERSION, + MIN_SUPPORTED_DOCS_VERSION, + ); + let searchResults = await client.search(createSearchArguments(query, finalSearchedVersion)); + + // If the initial search for a newer-than-stable version returns no results, it may be because + // the index for that version doesn't exist yet. In this case, fall back to the latest known + // stable version. + if ( + searchResults.results.every((result) => !('hits' in result) || result.hits.length === 0) && + finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION + ) { + finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION; + searchResults = await client.search(createSearchArguments(query, finalSearchedVersion)); + } + + const allHits = searchResults.results.flatMap((result) => (result as SearchResponse).hits); if (allHits.length === 0) { return { content: [ { type: 'text' as const, - text: 'No results found.', + text: `No results found for query "${query}" in Angular v${finalSearchedVersion} documentation.`, }, ], - structuredContent: { results: [] }, + structuredContent: { results: [], searchedVersion: finalSearchedVersion }, }; } const structuredResults = []; - const textContent = []; + const textContent: { + type: 'text'; + text: string; + annotations?: { audience: string[]; priority: number }; + }[] = [ + { + type: 'text' as const, + text: `Showing results for Angular v${finalSearchedVersion} documentation.`, + annotations: { + audience: ['assistant'], + priority: 0.9, + }, + }, + ]; // Process top hit first const topHit = allHits[0]; @@ -187,21 +240,27 @@ function createDocSearchHandler({ logger }: McpToolContext) { return { content: textContent, - structuredContent: { results: structuredResults }, + structuredContent: { results: structuredResults, searchedVersion: finalSearchedVersion }, }; }; } /** - * Strips HTML tags from a string. + * Strips HTML tags from a string using a regular expression. + * + * NOTE: This is a basic implementation and is not a full, correct HTML parser. It is, however, + * appropriate for this tool's specific use case because its input is always from a + * trusted source (angular.dev) and its output is consumed by a non-browser environment (an LLM). + * + * The regex first tries to match a complete tag (`<...>`). If it fails, it falls back to matching + * an incomplete tag (e.g., `]*>/g, '') + .replace(/<[^>]*>|<[a-zA-Z0-9/]+/g, '') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') @@ -252,17 +311,12 @@ function formatHitToParts(hit: Record): { title: string; breadc * @param query The search query string. * @returns The search arguments for the Algolia client. */ -function createSearchArguments( - query: string, - version: number | undefined, -): LegacySearchMethodProps { +function createSearchArguments(query: string, version: number): LegacySearchMethodProps { // Search arguments are based on adev's search service: // https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58 return [ { - // TODO: Consider major version specific indices once available - // indexName: `angular_${version ? `v${version}` : 'latest'}`, - indexName: 'angular_v17', + indexName: `angular_v${version}`, params: { query, attributesToRetrieve: [