From c957799c92dde57865dd0127540b4921123231d0 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:05:07 -0400 Subject: [PATCH] refactor(@angular/cli): add a documentation search tool to MCP server An additional MCP tool is now available with the `ng mcp` stdio MCP server that supports querying the `angular.dev` documentation. This uses the same algolia based search indexing that the documentation website uses. Rate limiting has been implemented with the MCP tool that may be adjusted based on feedback. The tool returns one or more URLs and titles for relevant documentation for a given query. Content of these search results are currently not fetched but rather this action is deferred to the host to determine which items are most relevant and should be retrieved from the documentation website. --- packages/angular/cli/BUILD.bazel | 1 + packages/angular/cli/package.json | 1 + .../angular/cli/src/commands/mcp/constants.ts | 13 ++ .../cli/src/commands/mcp/mcp-server.ts | 3 + .../cli/src/commands/mcp/tools/doc-search.ts | 123 ++++++++++++++ pnpm-lock.yaml | 152 ++++++++++++++++++ 6 files changed, 293 insertions(+) create mode 100644 packages/angular/cli/src/commands/mcp/constants.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/doc-search.ts diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index a25061997acf..2eacbb6b4ebf 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -51,6 +51,7 @@ ts_project( ":node_modules/@listr2/prompt-adapter-inquirer", ":node_modules/@modelcontextprotocol/sdk", ":node_modules/@yarnpkg/lockfile", + ":node_modules/algoliasearch", ":node_modules/ini", ":node_modules/jsonc-parser", ":node_modules/npm-package-arg", diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 798e0f129e45..18bce9d2bfee 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -30,6 +30,7 @@ "@modelcontextprotocol/sdk": "1.15.0", "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.32.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", "listr2": "9.0.0", diff --git a/packages/angular/cli/src/commands/mcp/constants.ts b/packages/angular/cli/src/commands/mcp/constants.ts new file mode 100644 index 000000000000..6530bfd34175 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/constants.ts @@ -0,0 +1,13 @@ +/** + * @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 + */ + +export const k1 = '@angular/cli'; +export const at = 'QBHBbOdEO4CmBOC2d7jNmg=='; +export const iv = Buffer.from([ + 0x97, 0xf4, 0x62, 0x95, 0x3e, 0x12, 0x76, 0x84, 0x8a, 0x09, 0x4a, 0xc9, 0xeb, 0xa2, 0x84, 0x69, +]); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 81a11ac6c94a..13ba22fbc688 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import { z } from 'zod'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; +import { registerDocSearchTool } from './tools/doc-search'; export async function createMcpServer(context: { workspace?: AngularWorkspace; @@ -129,5 +130,7 @@ export async function createMcpServer(context: { }, ); + await registerDocSearchTool(server); + return server; } diff --git a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts new file mode 100644 index 000000000000..5d7a682eb36f --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts @@ -0,0 +1,123 @@ +/** + * @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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch'; +import { createDecipheriv } from 'node:crypto'; +import { z } from 'zod'; +import { at, iv, k1 } from '../constants'; + +const ALGOLIA_APP_ID = 'L1XWT2UJ7F'; +// https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key +// This is a search only, rate limited key. It is sent within the URL of the query request. +// This is not the actual key. +const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c'; + +/** + * Registers a tool with the MCP server to search the Angular documentation. + * + * This tool uses Algolia to search the official Angular documentation. + * + * @param server The MCP server instance with which to register the tool. + */ +export async function registerDocSearchTool(server: McpServer): Promise { + let client: import('algoliasearch').SearchClient | undefined; + + server.registerTool( + 'search_documentation', + { + title: 'Search Angular Documentation (angular.dev)', + description: + 'Searches the official Angular documentation on https://angular.dev.' + + ' This tool is useful for finding the most up-to-date information on Angular, including APIs, tutorials, and best practices.' + + ' Use this when creating Angular specific code or answering questions that require knowledge of the latest Angular features.', + annotations: { + readOnlyHint: true, + }, + inputSchema: { + query: z + .string() + .describe( + 'The search query to use when searching the Angular documentation.' + + ' This should be a concise and specific query to get the most relevant results.', + ), + }, + }, + async ({ query }) => { + if (!client) { + 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'), + ); + } + + const { results } = await client.search(createSearchArguments(query)); + + // Convert results into text content entries instead of stringifying the entire object + const content = results.flatMap((result) => + (result as SearchResponse).hits.map((hit) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hierarchy = Object.values(hit.hierarchy as any).filter( + (x) => typeof x === 'string', + ); + const title = hierarchy.pop(); + const description = hierarchy.join(' > '); + + return { + type: 'text' as const, + text: `## ${title}\n${description}\nURL: ${hit.url}`, + }; + }), + ); + + return { content }; + }, + ); +} + +/** + * 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): 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_v17', + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 59b129e3cf36..191618116cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: '@yarnpkg/lockfile': specifier: 1.1.0 version: 1.1.0 + algoliasearch: + specifier: 5.32.0 + version: 5.32.0 ini: specifier: 5.0.0 version: 5.0.0 @@ -903,6 +906,58 @@ importers: packages: + '@algolia/client-abtesting@5.32.0': + resolution: {integrity: sha512-HG/6Eib6DnJYm/B2ijWFXr4txca/YOuA4K7AsEU0JBrOZSB+RU7oeDyNBPi3c0v0UDDqlkBqM3vBU/auwZlglA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.32.0': + resolution: {integrity: sha512-8Y9MLU72WFQOW3HArYv16+Wvm6eGmsqbxxM1qxtm0hvSASJbxCm+zQAZe5stqysTlcWo4BJ82KEH1PfgHbJAmQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.32.0': + resolution: {integrity: sha512-w8L+rgyXMCPBKmEdOT+RfgMrF0mT6HK60vPYWLz8DBs/P7yFdGo7urn99XCJvVLMSKXrIbZ2FMZ/i50nZTXnuQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.32.0': + resolution: {integrity: sha512-AdWfynhUeX7jz/LTiFU3wwzJembTbdLkQIOLs4n7PyBuxZ3jz4azV1CWbIP8AjUOFmul6uXbmYza+KqyS5CzOA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.32.0': + resolution: {integrity: sha512-bTupJY4xzGZYI4cEQcPlSjjIEzMvv80h7zXGrXY1Y0KC/n/SLiMv84v7Uy+B6AG1Kiy9FQm2ADChBLo1uEhGtQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.32.0': + resolution: {integrity: sha512-if+YTJw1G3nDKL2omSBjQltCHUQzbaHADkcPQrGFnIGhVyHU3Dzq4g46uEv8mrL5sxL8FjiS9LvekeUlL2NRqw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.32.0': + resolution: {integrity: sha512-kmK5nVkKb4DSUgwbveMKe4X3xHdMsPsOVJeEzBvFJ+oS7CkBPmpfHAEq+CcmiPJs20YMv6yVtUT9yPWL5WgAhg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.32.0': + resolution: {integrity: sha512-PZTqjJbx+fmPuT2ud1n4vYDSF1yrT//vOGI9HNYKNA0PM0xGUBWigf5gRivHsXa3oBnUlTyHV9j7Kqx5BHbVHQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.32.0': + resolution: {integrity: sha512-kYYoOGjvNQAmHDS1v5sBj+0uEL9RzYqH/TAdq8wmcV+/22weKt/fjh+6LfiqkS1SCZFYYrwGnirrUhUM36lBIQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.32.0': + resolution: {integrity: sha512-jyIBLdskjPAL7T1g57UMfUNx+PzvYbxKslwRUKBrBA6sNEsYCFdxJAtZSLUMmw6MC98RDt4ksmEl5zVMT5bsuw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.32.0': + resolution: {integrity: sha512-eDp14z92Gt6JlFgiexImcWWH+Lk07s/FtxcoDaGrE4UVBgpwqOO6AfQM6dXh1pvHxlDFbMJihHc/vj3gBhPjqQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.32.0': + resolution: {integrity: sha512-rnWVglh/K75hnaLbwSc2t7gCkbq1ldbPgeIKDUiEJxZ4mlguFgcltWjzpDQ/t1LQgxk9HdIFcQfM17Hid3aQ6Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.32.0': + resolution: {integrity: sha512-LbzQ04+VLkzXY4LuOzgyjqEv/46Gwrk55PldaglMJ4i4eDXSRXGKkwJpXFwsoU+c1HMQlHIyjJBhrfsfdyRmyQ==} + engines: {node: '>= 14.0.0'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -3345,6 +3400,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + algoliasearch@5.32.0: + resolution: {integrity: sha512-84xBncKNPBK8Ae89F65+SyVcOihrIbm/3N7to+GpRBHEUXGjA3ydWTMpcRW6jmFzkBQ/eqYy/y+J+NBpJWYjBg==} + engines: {node: '>= 14.0.0'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -8354,6 +8413,83 @@ packages: snapshots: + '@algolia/client-abtesting@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/client-analytics@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/client-common@5.32.0': {} + + '@algolia/client-insights@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/client-personalization@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/client-query-suggestions@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/client-search@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/ingestion@1.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/monitoring@1.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/recommend@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + + '@algolia/requester-browser-xhr@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + + '@algolia/requester-fetch@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + + '@algolia/requester-node-http@5.32.0': + dependencies: + '@algolia/client-common': 5.32.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.12 @@ -11212,6 +11348,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + algoliasearch@5.32.0: + dependencies: + '@algolia/client-abtesting': 5.32.0 + '@algolia/client-analytics': 5.32.0 + '@algolia/client-common': 5.32.0 + '@algolia/client-insights': 5.32.0 + '@algolia/client-personalization': 5.32.0 + '@algolia/client-query-suggestions': 5.32.0 + '@algolia/client-search': 5.32.0 + '@algolia/ingestion': 1.32.0 + '@algolia/monitoring': 1.32.0 + '@algolia/recommend': 5.32.0 + '@algolia/requester-browser-xhr': 5.32.0 + '@algolia/requester-fetch': 5.32.0 + '@algolia/requester-node-http': 5.32.0 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: