diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 613785018402..cf913b933bf4 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -61,7 +61,6 @@ ts_project( ":node_modules/@inquirer/prompts", ":node_modules/@listr2/prompt-adapter-inquirer", ":node_modules/@modelcontextprotocol/sdk", - ":node_modules/algoliasearch", ":node_modules/jsonc-parser", ":node_modules/listr2", ":node_modules/npm-package-arg", diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 91d09f630389..1d458f516d06 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -19,7 +19,6 @@ "@listr2/prompt-adapter-inquirer": "4.2.4", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", - "algoliasearch": "5.55.2", "jsonc-parser": "3.3.1", "listr2": "10.2.2", "npm-package-arg": "14.0.0", 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 145d819f278b..310dd4809067 100644 --- a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch'; import { createDecipheriv } from 'node:crypto'; import { Readable } from 'node:stream'; import { z } from 'zod'; @@ -32,7 +31,7 @@ const MIN_SUPPORTED_DOCS_VERSION = 17; * 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 LATEST_KNOWN_DOCS_VERSION = 22; const docSearchInputSchema = z.object({ query: z @@ -99,41 +98,86 @@ Searches the official Angular documentation (angular.dev) to answer questions ab }); function createDocSearchHandler({ logger }: McpToolContext) { - let client: import('algoliasearch').SearchClient | undefined; + let apiKey: string | undefined; - return async ({ query, includeTopContent, version }: DocSearchInput) => { - if (!client) { + async function performSearch(query: string, version: number) { + if (!apiKey) { const dcip = createDecipheriv( 'aes-256-gcm', (k1 + ALGOLIA_APP_ID).padEnd(32, '^'), iv, ).setAuthTag(Buffer.from(at, 'base64')); - const { searchClient } = await import('algoliasearch'); - client = searchClient( - ALGOLIA_APP_ID, - dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'), + apiKey = dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'); + } + + const url = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/angular_v${version}/query`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Algolia-Application-Id': ALGOLIA_APP_ID, + 'X-Algolia-API-Key': apiKey, + }, + body: JSON.stringify({ + query, + attributesToRetrieve: [ + 'hierarchy.lvl0', + 'hierarchy.lvl1', + 'hierarchy.lvl2', + 'hierarchy.lvl3', + 'hierarchy.lvl4', + 'hierarchy.lvl5', + 'hierarchy.lvl6', + 'content', + 'type', + 'url', + ], + hitsPerPage: 10, + }), + signal: AbortSignal.timeout(5000), // Timeout after 5 seconds + }); + + if (!response.ok) { + throw new Error( + `Search request failed with status ${response.status} (${response.statusText})`, ); } + const data = (await response.json()) as { hits: Record[] }; + + return data.hits; + } + + return async ({ query, includeTopContent, version }: DocSearchInput) => { let finalSearchedVersion = Math.max( version ?? LATEST_KNOWN_DOCS_VERSION, MIN_SUPPORTED_DOCS_VERSION, ); - let searchResults; + + let allHits: Record[] | undefined; try { - searchResults = await client.search(createSearchArguments(query, finalSearchedVersion)); - } catch {} + allHits = await performSearch(query, finalSearchedVersion); + } catch (error) { + logger.warn(`Error searching Angular v${finalSearchedVersion} documentation: ${error}`); + } // 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 && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) { + if ((!allHits || allHits.length === 0) && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) { + logger.warn( + `Documentation index for v${finalSearchedVersion} not found or empty. Falling back to v${LATEST_KNOWN_DOCS_VERSION}.`, + ); finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION; - searchResults = await client.search(createSearchArguments(query, finalSearchedVersion)); + try { + allHits = await performSearch(query, finalSearchedVersion); + } catch (error) { + logger.warn( + `Error searching fallback Angular v${finalSearchedVersion} documentation: ${error}`, + ); + } } - const allHits = searchResults?.results.flatMap((result) => (result as SearchResponse).hits); - if (!allHits?.length) { return { content: [ @@ -282,38 +326,3 @@ function formatHitToParts(hit: Record): { title: string; breadc return { title, breadcrumb }; } - -/** - * Creates the search arguments for an Algolia search. - * - * The arguments are based on the search implementation in `adev`. - * - * @param query The search query string. - * @returns The search arguments for the Algolia client. - */ -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 [ - { - indexName: `angular_v${version}`, - params: { - query, - attributesToRetrieve: [ - 'hierarchy.lvl0', - 'hierarchy.lvl1', - 'hierarchy.lvl2', - 'hierarchy.lvl3', - 'hierarchy.lvl4', - 'hierarchy.lvl5', - 'hierarchy.lvl6', - 'content', - 'type', - 'url', - ], - hitsPerPage: 10, - }, - type: 'default', - }, - ]; -} diff --git a/packages/angular/cli/src/commands/mcp/tools/doc-search_spec.ts b/packages/angular/cli/src/commands/mcp/tools/doc-search_spec.ts new file mode 100644 index 000000000000..b07bec0e9cf6 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search_spec.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createMockContext } from '../testing/test-utils'; +import { DOC_SEARCH_TOOL } from './doc-search'; + +describe('Doc Search Tool', () => { + let mockContext: ReturnType['context']; + let fetchSpy: jasmine.Spy; + + beforeEach(() => { + const { context } = createMockContext(); + mockContext = context; + + fetchSpy = spyOn(globalThis, 'fetch'); + }); + + it('should query the correct Algolia endpoint with headers and payload', async () => { + fetchSpy.and.resolveTo( + new Response( + JSON.stringify({ + hits: [ + { + hierarchy: { + lvl0: 'Docs', + lvl1: 'Standalone Components', + }, + url: 'https://angular.dev/guide/standalone-components', + }, + ], + }), + { status: 200, statusText: 'OK' }, + ), + ); + + const handler = DOC_SEARCH_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({ + query: 'standalone', + version: 22, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = fetchSpy.calls.first().args; + expect(calledUrl).toBe('https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query'); + expect(calledInit.method).toBe('POST'); + expect(calledInit.headers['X-Algolia-Application-Id']).toBe('L1XWT2UJ7F'); + expect(calledInit.headers['X-Algolia-API-Key']).toBeDefined(); + + const body = JSON.parse(calledInit.body); + expect(body.query).toBe('standalone'); + + expect(result.structuredContent.searchedVersion).toBe(22); + expect(result.structuredContent.results.length).toBe(1); + expect(result.structuredContent.results[0].title).toBe('Standalone Components'); + }); + + it('should fallback to latest known version if initial search returns no hits', async () => { + // First call returns empty hits + fetchSpy.and.returnValues( + Promise.resolve( + new Response(JSON.stringify({ hits: [] }), { status: 200, statusText: 'OK' }), + ), + // Second fallback call returns a hit + Promise.resolve( + new Response( + JSON.stringify({ + hits: [ + { + hierarchy: { + lvl0: 'Docs', + lvl1: 'Fallback Guide', + }, + url: 'https://angular.dev/guide/fallback', + }, + ], + }), + { status: 200, statusText: 'OK' }, + ), + ), + ); + + const handler = DOC_SEARCH_TOOL.factory(mockContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (handler as any)({ + query: 'some-query', + version: 24, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy.calls.first().args[0]).toBe( + 'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v24/query', + ); + expect(fetchSpy.calls.mostRecent().args[0]).toBe( + 'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query', + ); + + expect(result.structuredContent.searchedVersion).toBe(22); + expect(result.structuredContent.results.length).toBe(1); + expect(result.structuredContent.results[0].title).toBe('Fallback Guide'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f4f12f4da90..5fd79089adaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,9 +462,6 @@ importers: '@schematics/angular': specifier: workspace:0.0.0-PLACEHOLDER version: link:../../schematics/angular - algoliasearch: - specifier: 5.55.2 - version: 5.55.2 jsonc-parser: specifier: 3.3.1 version: 3.3.1 @@ -857,62 +854,6 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - '@algolia/abtesting@1.21.2': - resolution: {integrity: sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-abtesting@5.55.2': - resolution: {integrity: sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-analytics@5.55.2': - resolution: {integrity: sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-common@5.55.2': - resolution: {integrity: sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-insights@5.55.2': - resolution: {integrity: sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-personalization@5.55.2': - resolution: {integrity: sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-query-suggestions@5.55.2': - resolution: {integrity: sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-search@5.55.2': - resolution: {integrity: sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg==} - engines: {node: '>= 14.0.0'} - - '@algolia/ingestion@1.55.2': - resolution: {integrity: sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug==} - engines: {node: '>= 14.0.0'} - - '@algolia/monitoring@1.55.2': - resolution: {integrity: sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q==} - engines: {node: '>= 14.0.0'} - - '@algolia/recommend@5.55.2': - resolution: {integrity: sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-browser-xhr@5.55.2': - resolution: {integrity: sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-fetch@5.55.2': - resolution: {integrity: sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q==} - engines: {node: '>= 14.0.0'} - - '@algolia/requester-node-http@5.55.2': - resolution: {integrity: sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw==} - engines: {node: '>= 14.0.0'} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -3870,10 +3811,6 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - algoliasearch@5.55.2: - resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} - engines: {node: '>= 14.0.0'} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -8251,90 +8188,6 @@ snapshots: '@actions/io@3.0.2': {} - '@algolia/abtesting@1.21.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-abtesting@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-analytics@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-common@5.55.2': {} - - '@algolia/client-insights@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-personalization@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-query-suggestions@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/client-search@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/ingestion@1.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/monitoring@1.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/recommend@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - - '@algolia/requester-browser-xhr@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - - '@algolia/requester-fetch@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - - '@algolia/requester-node-http@5.55.2': - dependencies: - '@algolia/client-common': 5.55.2 - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -11608,23 +11461,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - algoliasearch@5.55.2: - dependencies: - '@algolia/abtesting': 1.21.2 - '@algolia/client-abtesting': 5.55.2 - '@algolia/client-analytics': 5.55.2 - '@algolia/client-common': 5.55.2 - '@algolia/client-insights': 5.55.2 - '@algolia/client-personalization': 5.55.2 - '@algolia/client-query-suggestions': 5.55.2 - '@algolia/client-search': 5.55.2 - '@algolia/ingestion': 1.55.2 - '@algolia/monitoring': 1.55.2 - '@algolia/recommend': 5.55.2 - '@algolia/requester-browser-xhr': 5.55.2 - '@algolia/requester-fetch': 5.55.2 - '@algolia/requester-node-http': 5.55.2 - ansi-colors@4.1.3: {} ansi-escapes@7.3.0: