Skip to content

refactor(@angular/cli): add a documentation search tool to MCP server #30705

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/angular/cli/src/commands/mcp/constants.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
3 changes: 3 additions & 0 deletions packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,5 +130,7 @@ export async function createMcpServer(context: {
},
);

await registerDocSearchTool(server);

return server;
}
123 changes: 123 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/doc-search.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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',
},
];
}
152 changes: 152 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.