Skip to content

Commit 16ae6a1

Browse files
committed
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.
1 parent 331c85d commit 16ae6a1

File tree

6 files changed

+293
-0
lines changed

6 files changed

+293
-0
lines changed

packages/angular/cli/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ ts_project(
5151
":node_modules/@listr2/prompt-adapter-inquirer",
5252
":node_modules/@modelcontextprotocol/sdk",
5353
":node_modules/@yarnpkg/lockfile",
54+
":node_modules/algoliasearch",
5455
":node_modules/ini",
5556
":node_modules/jsonc-parser",
5657
":node_modules/npm-package-arg",

packages/angular/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@modelcontextprotocol/sdk": "1.15.1",
3131
"@schematics/angular": "workspace:0.0.0-PLACEHOLDER",
3232
"@yarnpkg/lockfile": "1.1.0",
33+
"algoliasearch": "5.32.0",
3334
"ini": "5.0.0",
3435
"jsonc-parser": "3.3.1",
3536
"listr2": "9.0.1",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export const k1 = '@angular/cli';
10+
export const at = 'QBHBbOdEO4CmBOC2d7jNmg==';
11+
export const iv = Buffer.from([
12+
0x97, 0xf4, 0x62, 0x95, 0x3e, 0x12, 0x76, 0x84, 0x8a, 0x09, 0x4a, 0xc9, 0xeb, 0xa2, 0x84, 0x69,
13+
]);

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import path from 'node:path';
1212
import { z } from 'zod';
1313
import type { AngularWorkspace } from '../../utilities/config';
1414
import { VERSION } from '../../utilities/version';
15+
import { registerDocSearchTool } from './tools/doc-search';
1516

1617
export async function createMcpServer(context: {
1718
workspace?: AngularWorkspace;
@@ -129,5 +130,7 @@ export async function createMcpServer(context: {
129130
},
130131
);
131132

133+
await registerDocSearchTool(server);
134+
132135
return server;
133136
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10+
import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch';
11+
import { createDecipheriv } from 'node:crypto';
12+
import { z } from 'zod';
13+
import { at, iv, k1 } from '../constants';
14+
15+
const ALGOLIA_APP_ID = 'L1XWT2UJ7F';
16+
// https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key
17+
// This is a search only, rate limited key. It is sent within the URL of the query request.
18+
// This is not the actual key.
19+
const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c';
20+
21+
/**
22+
* Registers a tool with the MCP server to search the Angular documentation.
23+
*
24+
* This tool uses Algolia to search the official Angular documentation.
25+
*
26+
* @param server The MCP server instance with which to register the tool.
27+
*/
28+
export async function registerDocSearchTool(server: McpServer): Promise<void> {
29+
let client: import('algoliasearch').SearchClient | undefined;
30+
31+
server.registerTool(
32+
'search_documentation',
33+
{
34+
title: 'Search Angular Documentation (angular.dev)',
35+
description:
36+
'Searches the official Angular documentation on https://angular.dev.' +
37+
' This tool is useful for finding the most up-to-date information on Angular, including APIs, tutorials, and best practices.' +
38+
' Use this when creating Angular specific code or answering questions that require knowledge of the latest Angular features.',
39+
annotations: {
40+
readOnlyHint: true,
41+
},
42+
inputSchema: {
43+
query: z
44+
.string()
45+
.describe(
46+
'The search query to use when searching the Angular documentation.' +
47+
' This should be a concise and specific query to get the most relevant results.',
48+
),
49+
},
50+
},
51+
async ({ query }) => {
52+
if (!client) {
53+
const dcip = createDecipheriv(
54+
'aes-256-gcm',
55+
(k1 + ALGOLIA_APP_ID).padEnd(32, '^'),
56+
iv,
57+
).setAuthTag(Buffer.from(at, 'base64'));
58+
const { searchClient } = await import('algoliasearch');
59+
client = searchClient(
60+
ALGOLIA_APP_ID,
61+
dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'),
62+
);
63+
}
64+
65+
const { results } = await client.search(createSearchArguments(query));
66+
67+
// Convert results into text content entries instead of stringifying the entire object
68+
const content = results.flatMap((result) =>
69+
(result as SearchResponse).hits.map((hit) => {
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
const hierarchy = Object.values(hit.hierarchy as any).filter(
72+
(x) => typeof x === 'string',
73+
);
74+
const title = hierarchy.pop();
75+
const description = hierarchy.join(' > ');
76+
77+
return {
78+
type: 'text' as const,
79+
text: `## ${title}\n${description}\nURL: ${hit.url}`,
80+
};
81+
}),
82+
);
83+
84+
return { content };
85+
},
86+
);
87+
}
88+
89+
/**
90+
* Creates the search arguments for an Algolia search.
91+
*
92+
* The arguments are based on the search implementation in `adev`.
93+
*
94+
* @param query The search query string.
95+
* @returns The search arguments for the Algolia client.
96+
*/
97+
function createSearchArguments(query: string): LegacySearchMethodProps {
98+
// Search arguments are based on adev's search service:
99+
// https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58
100+
return [
101+
{
102+
// TODO: Consider major version specific indices once available
103+
indexName: 'angular_v17',
104+
params: {
105+
query,
106+
attributesToRetrieve: [
107+
'hierarchy.lvl0',
108+
'hierarchy.lvl1',
109+
'hierarchy.lvl2',
110+
'hierarchy.lvl3',
111+
'hierarchy.lvl4',
112+
'hierarchy.lvl5',
113+
'hierarchy.lvl6',
114+
'content',
115+
'type',
116+
'url',
117+
],
118+
hitsPerPage: 10,
119+
},
120+
type: 'default',
121+
},
122+
];
123+
}

pnpm-lock.yaml

Lines changed: 152 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)