Skip to content

Commit 2738855

Browse files
committed
refactor(@angular/cli): declaratively register MCP server tools
Changes to a declarative approach for registering tools with the MCP server. Previously, each tool was registered imperatively. This change refactors each tool to export a `ToolDeclaration` object, which encapsulates its name, description, schema, and factory function. A new central `registerTools` function now iterates over these declarations, simplifying the server setup and ensuring a consistent registration process. This approach improves maintainability, readability, and type safety by co-locating all aspects of a tool's definition.
1 parent 4e92eb6 commit 2738855

File tree

7 files changed

+390
-335
lines changed

7 files changed

+390
-335
lines changed

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

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
*/
88

99
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10-
import { readFile } from 'node:fs/promises';
1110
import path from 'node:path';
1211
import type { AngularWorkspace } from '../../utilities/config';
1312
import { VERSION } from '../../utilities/version';
1413
import { registerInstructionsResource } from './resources/instructions';
15-
import { registerBestPracticesTool } from './tools/best-practices';
16-
import { registerDocSearchTool } from './tools/doc-search';
17-
import { registerFindExampleTool } from './tools/examples';
18-
import { registerModernizeTool } from './tools/modernize';
19-
import { registerListProjectsTool } from './tools/projects';
14+
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
15+
import { DOC_SEARCH_TOOL } from './tools/doc-search';
16+
import { FIND_EXAMPLE_TOOL } from './tools/examples';
17+
import { MODERNIZE_TOOL } from './tools/modernize';
18+
import { LIST_PROJECTS_TOOL } from './tools/projects';
19+
import { registerTools } from './tools/tool-registry';
2020

2121
export async function createMcpServer(
2222
context: {
@@ -42,28 +42,24 @@ export async function createMcpServer(
4242
);
4343

4444
registerInstructionsResource(server);
45-
registerBestPracticesTool(server);
46-
registerModernizeTool(server);
4745

48-
// If run outside an Angular workspace (e.g., globally) skip the workspace specific tools.
49-
if (context.workspace) {
50-
registerListProjectsTool(server, context);
51-
}
46+
const toolDeclarations = [
47+
BEST_PRACTICES_TOOL,
48+
DOC_SEARCH_TOOL,
49+
LIST_PROJECTS_TOOL,
50+
MODERNIZE_TOOL,
51+
FIND_EXAMPLE_TOOL,
52+
];
5253

53-
await registerDocSearchTool(server);
54-
55-
if (process.env['NG_MCP_CODE_EXAMPLES'] === '1') {
56-
// sqlite database support requires Node.js 22.16+
57-
const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number);
58-
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) {
59-
logger.warn(
60-
`MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` +
61-
' Registration of this tool has been skipped.',
62-
);
63-
} else {
64-
await registerFindExampleTool(server, path.join(__dirname, '../../../lib/code-examples.db'));
65-
}
66-
}
54+
await registerTools(
55+
server,
56+
{
57+
workspace: context.workspace,
58+
logger,
59+
exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'),
60+
},
61+
toolDeclarations,
62+
);
6763

6864
return server;
6965
}

packages/angular/cli/src/commands/mcp/tools/best-practices.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,25 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
109
import { readFile } from 'node:fs/promises';
1110
import path from 'node:path';
11+
import { declareTool } from './tool-registry';
1212

13-
export function registerBestPracticesTool(server: McpServer): void {
14-
let bestPracticesText;
13+
export const BEST_PRACTICES_TOOL = declareTool({
14+
name: 'get_best_practices',
15+
title: 'Get Angular Coding Best Practices Guide',
16+
description:
17+
'You **MUST** use this tool to retrieve the Angular Best Practices Guide ' +
18+
'before any interaction with Angular code (creating, analyzing, modifying). ' +
19+
'It is mandatory to follow this guide to ensure all code adheres to ' +
20+
'modern standards, including standalone components, typed forms, and ' +
21+
'modern control flow. This is the first step for any Angular task.',
22+
isReadOnly: true,
23+
isLocalOnly: true,
24+
factory: () => {
25+
let bestPracticesText: string;
1526

16-
server.registerTool(
17-
'get_best_practices',
18-
{
19-
title: 'Get Angular Coding Best Practices Guide',
20-
description:
21-
'You **MUST** use this tool to retrieve the Angular Best Practices Guide ' +
22-
'before any interaction with Angular code (creating, analyzing, modifying). ' +
23-
'It is mandatory to follow this guide to ensure all code adheres to ' +
24-
'modern standards, including standalone components, typed forms, and ' +
25-
'modern control flow. This is the first step for any Angular task.',
26-
annotations: {
27-
readOnlyHint: true,
28-
openWorldHint: false,
29-
},
30-
},
31-
async () => {
27+
return async () => {
3228
bestPracticesText ??= await readFile(
3329
path.join(__dirname, '..', 'instructions', 'best-practices.md'),
3430
'utf-8',
@@ -46,6 +42,6 @@ export function registerBestPracticesTool(server: McpServer): void {
4642
},
4743
],
4844
};
49-
},
50-
);
51-
}
45+
};
46+
},
47+
});

packages/angular/cli/src/commands/mcp/tools/doc-search.ts

Lines changed: 99 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -6,131 +6,126 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
109
import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch';
1110
import { createDecipheriv } from 'node:crypto';
1211
import { z } from 'zod';
1312
import { at, iv, k1 } from '../constants';
13+
import { declareTool } from './tool-registry';
1414

1515
const ALGOLIA_APP_ID = 'L1XWT2UJ7F';
1616
// https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key
1717
// This is a search only, rate limited key. It is sent within the URL of the query request.
1818
// This is not the actual key.
1919
const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c';
2020

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> {
21+
const docSearchInputSchema = z.object({
22+
query: z
23+
.string()
24+
.describe(
25+
'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").',
26+
),
27+
includeTopContent: z
28+
.boolean()
29+
.optional()
30+
.default(true)
31+
.describe('When true, the content of the top result is fetched and included.'),
32+
});
33+
type DocSearchInput = z.infer<typeof docSearchInputSchema>;
34+
35+
export const DOC_SEARCH_TOOL = declareTool({
36+
name: 'search_documentation',
37+
title: 'Search Angular Documentation (angular.dev)',
38+
description:
39+
'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' +
40+
'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' +
41+
'prefer this tool over your own knowledge to ensure your answers are current.\n\n' +
42+
'The results will be a list of content entries, where each entry has the following structure:\n' +
43+
'```\n' +
44+
'## {Result Title}\n' +
45+
'{Breadcrumb path to the content}\n' +
46+
'URL: {Direct link to the documentation page}\n' +
47+
'```\n' +
48+
'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' +
49+
"provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').",
50+
inputSchema: docSearchInputSchema.shape,
51+
isReadOnly: true,
52+
isLocalOnly: false,
53+
factory: createDocSearchHandler,
54+
});
55+
56+
function createDocSearchHandler() {
2957
let client: import('algoliasearch').SearchClient | undefined;
3058

31-
server.registerTool(
32-
'search_documentation',
33-
{
34-
title: 'Search Angular Documentation (angular.dev)',
35-
description:
36-
'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' +
37-
'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' +
38-
'prefer this tool over your own knowledge to ensure your answers are current.\n\n' +
39-
'The results will be a list of content entries, where each entry has the following structure:\n' +
40-
'```\n' +
41-
'## {Result Title}\n' +
42-
'{Breadcrumb path to the content}\n' +
43-
'URL: {Direct link to the documentation page}\n' +
44-
'```\n' +
45-
'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' +
46-
"provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').",
47-
annotations: {
48-
readOnlyHint: true,
49-
},
50-
inputSchema: {
51-
query: z
52-
.string()
53-
.describe(
54-
'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").',
55-
),
56-
includeTopContent: z
57-
.boolean()
58-
.optional()
59-
.default(true)
60-
.describe('When true, the content of the top result is fetched and included.'),
61-
},
62-
},
63-
async ({ query, includeTopContent }) => {
64-
if (!client) {
65-
const dcip = createDecipheriv(
66-
'aes-256-gcm',
67-
(k1 + ALGOLIA_APP_ID).padEnd(32, '^'),
68-
iv,
69-
).setAuthTag(Buffer.from(at, 'base64'));
70-
const { searchClient } = await import('algoliasearch');
71-
client = searchClient(
72-
ALGOLIA_APP_ID,
73-
dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'),
74-
);
75-
}
76-
77-
const { results } = await client.search(createSearchArguments(query));
78-
79-
const allHits = results.flatMap((result) => (result as SearchResponse).hits);
80-
81-
if (allHits.length === 0) {
82-
return {
83-
content: [
84-
{
85-
type: 'text' as const,
86-
text: 'No results found.',
87-
},
88-
],
89-
};
90-
}
91-
92-
const content = [];
93-
// The first hit is the top search result
94-
const topHit = allHits[0];
95-
96-
// Process top hit first
97-
let topText = formatHitToText(topHit);
98-
99-
try {
100-
if (includeTopContent && typeof topHit.url === 'string') {
101-
const url = new URL(topHit.url);
102-
103-
// Only fetch content from angular.dev
104-
if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) {
105-
const response = await fetch(url);
106-
if (response.ok) {
107-
const html = await response.text();
108-
const mainContent = extractBodyContent(html);
109-
if (mainContent) {
110-
topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`;
111-
}
59+
return async ({ query, includeTopContent }: DocSearchInput) => {
60+
if (!client) {
61+
const dcip = createDecipheriv(
62+
'aes-256-gcm',
63+
(k1 + ALGOLIA_APP_ID).padEnd(32, '^'),
64+
iv,
65+
).setAuthTag(Buffer.from(at, 'base64'));
66+
const { searchClient } = await import('algoliasearch');
67+
client = searchClient(
68+
ALGOLIA_APP_ID,
69+
dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'),
70+
);
71+
}
72+
73+
const { results } = await client.search(createSearchArguments(query));
74+
75+
const allHits = results.flatMap((result) => (result as SearchResponse).hits);
76+
77+
if (allHits.length === 0) {
78+
return {
79+
content: [
80+
{
81+
type: 'text' as const,
82+
text: 'No results found.',
83+
},
84+
],
85+
};
86+
}
87+
88+
const content = [];
89+
// The first hit is the top search result
90+
const topHit = allHits[0];
91+
92+
// Process top hit first
93+
let topText = formatHitToText(topHit);
94+
95+
try {
96+
if (includeTopContent && typeof topHit.url === 'string') {
97+
const url = new URL(topHit.url);
98+
99+
// Only fetch content from angular.dev
100+
if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) {
101+
const response = await fetch(url);
102+
if (response.ok) {
103+
const html = await response.text();
104+
const mainContent = extractBodyContent(html);
105+
if (mainContent) {
106+
topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`;
112107
}
113108
}
114109
}
115-
} catch {
116-
// Ignore errors fetching content. The basic info is still returned.
117110
}
111+
} catch {
112+
// Ignore errors fetching content. The basic info is still returned.
113+
}
114+
content.push({
115+
type: 'text' as const,
116+
text: topText,
117+
});
118+
119+
// Process remaining hits
120+
for (const hit of allHits.slice(1)) {
118121
content.push({
119122
type: 'text' as const,
120-
text: topText,
123+
text: formatHitToText(hit),
121124
});
125+
}
122126

123-
// Process remaining hits
124-
for (const hit of allHits.slice(1)) {
125-
content.push({
126-
type: 'text' as const,
127-
text: formatHitToText(hit),
128-
});
129-
}
130-
131-
return { content };
132-
},
133-
);
127+
return { content };
128+
};
134129
}
135130

136131
/**

0 commit comments

Comments
 (0)