From de9c0845de321688542f31bb54217d3dd887cfc9 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:28:31 -0400 Subject: [PATCH 1/5] 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. --- .../cli/src/commands/mcp/mcp-server.ts | 48 ++--- .../src/commands/mcp/tools/best-practices.ts | 40 ++-- .../cli/src/commands/mcp/tools/doc-search.ts | 203 +++++++++--------- .../cli/src/commands/mcp/tools/examples.ts | 140 ++++++------ .../cli/src/commands/mcp/tools/modernize.ts | 67 +++--- .../cli/src/commands/mcp/tools/projects.ts | 155 +++++++------ .../src/commands/mcp/tools/tool-registry.ts | 71 ++++++ 7 files changed, 389 insertions(+), 335 deletions(-) create mode 100644 packages/angular/cli/src/commands/mcp/tools/tool-registry.ts diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 18adce7478d1..5234b44723ac 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -7,16 +7,16 @@ */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { readFile } from 'node:fs/promises'; import path from 'node:path'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; import { registerInstructionsResource } from './resources/instructions'; -import { registerBestPracticesTool } from './tools/best-practices'; -import { registerDocSearchTool } from './tools/doc-search'; -import { registerFindExampleTool } from './tools/examples'; -import { registerModernizeTool } from './tools/modernize'; -import { registerListProjectsTool } from './tools/projects'; +import { BEST_PRACTICES_TOOL } from './tools/best-practices'; +import { DOC_SEARCH_TOOL } from './tools/doc-search'; +import { FIND_EXAMPLE_TOOL } from './tools/examples'; +import { MODERNIZE_TOOL } from './tools/modernize'; +import { LIST_PROJECTS_TOOL } from './tools/projects'; +import { registerTools } from './tools/tool-registry'; export async function createMcpServer( context: { @@ -42,28 +42,24 @@ export async function createMcpServer( ); registerInstructionsResource(server); - registerBestPracticesTool(server); - registerModernizeTool(server); - // If run outside an Angular workspace (e.g., globally) skip the workspace specific tools. - if (context.workspace) { - registerListProjectsTool(server, context); - } + const toolDeclarations = [ + BEST_PRACTICES_TOOL, + DOC_SEARCH_TOOL, + LIST_PROJECTS_TOOL, + MODERNIZE_TOOL, + FIND_EXAMPLE_TOOL, + ]; - await registerDocSearchTool(server); - - if (process.env['NG_MCP_CODE_EXAMPLES'] === '1') { - // sqlite database support requires Node.js 22.16+ - const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); - if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { - logger.warn( - `MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` + - ' Registration of this tool has been skipped.', - ); - } else { - await registerFindExampleTool(server, path.join(__dirname, '../../../lib/code-examples.db')); - } - } + await registerTools( + server, + { + workspace: context.workspace, + logger, + exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'), + }, + toolDeclarations, + ); return server; } diff --git a/packages/angular/cli/src/commands/mcp/tools/best-practices.ts b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts index e8139ac3b794..40c0ec8cf464 100644 --- a/packages/angular/cli/src/commands/mcp/tools/best-practices.ts +++ b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts @@ -6,29 +6,25 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { declareTool } from './tool-registry'; -export function registerBestPracticesTool(server: McpServer): void { - let bestPracticesText; +export const BEST_PRACTICES_TOOL = declareTool({ + name: 'get_best_practices', + title: 'Get Angular Coding Best Practices Guide', + description: + 'You **MUST** use this tool to retrieve the Angular Best Practices Guide ' + + 'before any interaction with Angular code (creating, analyzing, modifying). ' + + 'It is mandatory to follow this guide to ensure all code adheres to ' + + 'modern standards, including standalone components, typed forms, and ' + + 'modern control flow. This is the first step for any Angular task.', + isReadOnly: true, + isLocalOnly: true, + factory: () => { + let bestPracticesText: string; - server.registerTool( - 'get_best_practices', - { - title: 'Get Angular Coding Best Practices Guide', - description: - 'You **MUST** use this tool to retrieve the Angular Best Practices Guide ' + - 'before any interaction with Angular code (creating, analyzing, modifying). ' + - 'It is mandatory to follow this guide to ensure all code adheres to ' + - 'modern standards, including standalone components, typed forms, and ' + - 'modern control flow. This is the first step for any Angular task.', - annotations: { - readOnlyHint: true, - openWorldHint: false, - }, - }, - async () => { + return async () => { bestPracticesText ??= await readFile( path.join(__dirname, '..', 'instructions', 'best-practices.md'), 'utf-8', @@ -46,6 +42,6 @@ export function registerBestPracticesTool(server: McpServer): void { }, ], }; - }, - ); -} + }; + }, +}); 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 a92df1c8aa6a..53ca94928d7d 100644 --- a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts @@ -6,11 +6,11 @@ * 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'; +import { declareTool } from './tool-registry'; const ALGOLIA_APP_ID = 'L1XWT2UJ7F'; // https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key @@ -18,119 +18,114 @@ const ALGOLIA_APP_ID = 'L1XWT2UJ7F'; // 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 { +const docSearchInputSchema = z.object({ + query: z + .string() + .describe( + 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").', + ), + includeTopContent: z + .boolean() + .optional() + .default(true) + .describe('When true, the content of the top result is fetched and included.'), +}); +type DocSearchInput = z.infer; + +export const DOC_SEARCH_TOOL = declareTool({ + name: 'search_documentation', + title: 'Search Angular Documentation (angular.dev)', + description: + 'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' + + 'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' + + 'prefer this tool over your own knowledge to ensure your answers are current.\n\n' + + 'The results will be a list of content entries, where each entry has the following structure:\n' + + '```\n' + + '## {Result Title}\n' + + '{Breadcrumb path to the content}\n' + + 'URL: {Direct link to the documentation page}\n' + + '```\n' + + 'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' + + "provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').", + inputSchema: docSearchInputSchema.shape, + isReadOnly: true, + isLocalOnly: false, + factory: createDocSearchHandler, +}); + +function createDocSearchHandler() { let client: import('algoliasearch').SearchClient | undefined; - server.registerTool( - 'search_documentation', - { - title: 'Search Angular Documentation (angular.dev)', - description: - 'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' + - 'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' + - 'prefer this tool over your own knowledge to ensure your answers are current.\n\n' + - 'The results will be a list of content entries, where each entry has the following structure:\n' + - '```\n' + - '## {Result Title}\n' + - '{Breadcrumb path to the content}\n' + - 'URL: {Direct link to the documentation page}\n' + - '```\n' + - 'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' + - "provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').", - annotations: { - readOnlyHint: true, - }, - inputSchema: { - query: z - .string() - .describe( - 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").', - ), - includeTopContent: z - .boolean() - .optional() - .default(true) - .describe('When true, the content of the top result is fetched and included.'), - }, - }, - async ({ query, includeTopContent }) => { - 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)); - - const allHits = results.flatMap((result) => (result as SearchResponse).hits); - - if (allHits.length === 0) { - return { - content: [ - { - type: 'text' as const, - text: 'No results found.', - }, - ], - }; - } - - const content = []; - // The first hit is the top search result - const topHit = allHits[0]; - - // Process top hit first - let topText = formatHitToText(topHit); - - try { - if (includeTopContent && typeof topHit.url === 'string') { - const url = new URL(topHit.url); - - // Only fetch content from angular.dev - if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) { - const response = await fetch(url); - if (response.ok) { - const html = await response.text(); - const mainContent = extractBodyContent(html); - if (mainContent) { - topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`; - } + return async ({ query, includeTopContent }: DocSearchInput) => { + 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)); + + const allHits = results.flatMap((result) => (result as SearchResponse).hits); + + if (allHits.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No results found.', + }, + ], + }; + } + + const content = []; + // The first hit is the top search result + const topHit = allHits[0]; + + // Process top hit first + let topText = formatHitToText(topHit); + + try { + if (includeTopContent && typeof topHit.url === 'string') { + const url = new URL(topHit.url); + + // Only fetch content from angular.dev + if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) { + const response = await fetch(url); + if (response.ok) { + const html = await response.text(); + const mainContent = extractBodyContent(html); + if (mainContent) { + topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`; } } } - } catch { - // Ignore errors fetching content. The basic info is still returned. } + } catch { + // Ignore errors fetching content. The basic info is still returned. + } + content.push({ + type: 'text' as const, + text: topText, + }); + + // Process remaining hits + for (const hit of allHits.slice(1)) { content.push({ type: 'text' as const, - text: topText, + text: formatHitToText(hit), }); + } - // Process remaining hits - for (const hit of allHits.slice(1)) { - content.push({ - type: 'text' as const, - text: formatHitToText(hit), - }); - } - - return { content }; - }, - ); + return { content }; + }; } /** diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 9d010410be3d..79861d58f681 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -6,50 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { glob, readFile } from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; +import { McpToolContext, declareTool } from './tool-registry'; -/** - * Registers the `find_examples` tool with the MCP server. - * - * This tool allows users to search for best-practice Angular code examples - * from a local SQLite database. - * - * @param server The MCP server instance. - * @param exampleDatabasePath The path to the SQLite database file containing the examples. - */ -export async function registerFindExampleTool( - server: McpServer, - exampleDatabasePath: string, -): Promise { - let db: import('node:sqlite').DatabaseSync | undefined; - let queryStatement: import('node:sqlite').StatementSync | undefined; - - // Runtime directory of examples uses an in-memory database - if (process.env['NG_MCP_EXAMPLES_DIR']) { - db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']); - } - - suppressSqliteWarning(); - - server.registerTool( - 'find_examples', - { - title: 'Find Angular Code Examples', - description: - 'Before writing or modifying any Angular code including templates, ' + - '**ALWAYS** use this tool to find current best-practice examples. ' + - 'This is critical for ensuring code quality and adherence to modern Angular standards. ' + - 'This tool searches a curated database of approved Angular code examples and returns the most relevant results for your query. ' + - 'Example Use Cases: ' + - "1) Creating new components, directives, or services (e.g., query: 'standalone component' or 'signal input'). " + - "2) Implementing core features (e.g., query: 'lazy load route', 'httpinterceptor', or 'route guard'). " + - "3) Refactoring existing code to use modern patterns (e.g., query: 'ngfor trackby' or 'form validation').", - inputSchema: { - query: z.string().describe( - `Performs a full-text search using FTS5 syntax. The query should target relevant Angular concepts. +const findExampleInputSchema = z.object({ + query: z.string().describe( + `Performs a full-text search using FTS5 syntax. The query should target relevant Angular concepts. Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation): - AND (default): Space-separated terms are combined with AND. @@ -71,35 +35,81 @@ Examples of queries: - Find signal inputs: 'signal input' - Find lazy loading a route: 'lazy load route' - Find forms with validation: 'form AND (validation OR validator)'`, - ), - }, - annotations: { - readOnlyHint: true, - openWorldHint: false, - }, - }, - async ({ query }) => { - if (!db) { - const { DatabaseSync } = await import('node:sqlite'); - db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); - } - if (!queryStatement) { - queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;'); - } + ), +}); +type FindExampleInput = z.infer; + +export const FIND_EXAMPLE_TOOL = declareTool({ + name: 'find_examples', + title: 'Find Angular Code Examples', + description: + 'Before writing or modifying any Angular code including templates, ' + + '**ALWAYS** use this tool to find current best-practice examples. ' + + 'This is critical for ensuring code quality and adherence to modern Angular standards. ' + + 'This tool searches a curated database of approved Angular code examples and returns the most relevant results for your query. ' + + 'Example Use Cases: ' + + "1) Creating new components, directives, or services (e.g., query: 'standalone component' or 'signal input'). " + + "2) Implementing core features (e.g., query: 'lazy load route', 'httpinterceptor', or 'route guard'). " + + "3) Refactoring existing code to use modern patterns (e.g., query: 'ngfor trackby' or 'form validation').", + inputSchema: findExampleInputSchema.shape, + isReadOnly: true, + isLocalOnly: true, + shouldRegister: ({ logger }) => { + if (process.env['NG_MCP_CODE_EXAMPLES'] !== '1') { + return false; + } + + // sqlite database support requires Node.js 22.16+ + const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); + if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { + logger.warn( + `MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` + + ' Registration of this tool has been skipped.', + ); - const sanitizedQuery = escapeSearchQuery(query); + return false; + } + + return true; + }, + factory: createFindExampleHandler, +}); + +async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) { + let db: import('node:sqlite').DatabaseSync | undefined; + let queryStatement: import('node:sqlite').StatementSync | undefined; + + if (process.env['NG_MCP_EXAMPLES_DIR']) { + db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']); + } + + suppressSqliteWarning(); - // Query database and return results as text content - const content = []; - for (const exampleRecord of queryStatement.all(sanitizedQuery)) { - content.push({ type: 'text' as const, text: exampleRecord['content'] as string }); + return async ({ query }: FindExampleInput) => { + if (!db) { + if (!exampleDatabasePath) { + // This should be prevented by the registration logic in mcp-server.ts + throw new Error('Example database path is not available.'); } + const { DatabaseSync } = await import('node:sqlite'); + db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); + } + if (!queryStatement) { + queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;'); + } - return { - content, - }; - }, - ); + const sanitizedQuery = escapeSearchQuery(query); + + // Query database and return results as text content + const content = []; + for (const exampleRecord of queryStatement.all(sanitizedQuery)) { + content.push({ type: 'text' as const, text: exampleRecord['content'] as string }); + } + + return { + content, + }; + }; } /** diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize.ts b/packages/angular/cli/src/commands/mcp/tools/modernize.ts index 4f8ab5fb9170..8fc427d92201 100644 --- a/packages/angular/cli/src/commands/mcp/tools/modernize.ts +++ b/packages/angular/cli/src/commands/mcp/tools/modernize.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import { declareTool } from './tool-registry'; interface Transformation { name: string; @@ -125,38 +125,33 @@ export async function runModernization(input: ModernizeInput) { }; } -export function registerModernizeTool(server: McpServer): void { - server.registerTool( - 'modernize', - { - title: 'Modernize Angular Code', - description: - '\n' + - 'This tool modernizes Angular code by applying the latest best practices and syntax improvements, ' + - 'ensuring it is idiomatic, readable, and maintainable.\n\n' + - '\n' + - '\n' + - '* After generating new code: Run this tool immediately after creating new Angular components, directives, ' + - 'or services to ensure they adhere to modern standards.\n' + - '* On existing code: Apply to existing TypeScript files (.ts) and Angular templates (.ng.html) to update ' + - 'them with the latest features, such as the new built-in control flow syntax.\n\n' + - '* When the user asks for a specific transformation: When the transformation list is populated, ' + - 'these specific ones will be ran on the inputs.\n' + - '\n' + - '\n' + - TRANSFORMATIONS.map((t) => `* ${t.name}: ${t.description}`).join('\n') + - '\n\n', - annotations: { - readOnlyHint: true, - }, - inputSchema: modernizeInputSchema.shape, - outputSchema: { - instructions: z - .array(z.string()) - .optional() - .describe('A list of instructions on how to run the migrations.'), - }, - }, - (input) => runModernization(input), - ); -} +export const MODERNIZE_TOOL = declareTool({ + name: 'modernize', + title: 'Modernize Angular Code', + description: + '\n' + + 'This tool modernizes Angular code by applying the latest best practices and syntax improvements, ' + + 'ensuring it is idiomatic, readable, and maintainable.\n\n' + + '\n' + + '\n' + + '* After generating new code: Run this tool immediately after creating new Angular components, directives, ' + + 'or services to ensure they adhere to modern standards.\n' + + '* On existing code: Apply to existing TypeScript files (.ts) and Angular templates (.ng.html) to update ' + + 'them with the latest features, such as the new built-in control flow syntax.\n\n' + + '* When the user asks for a specific transformation: When the transformation list is populated, ' + + 'these specific ones will be ran on the inputs.\n' + + '\n' + + '\n' + + TRANSFORMATIONS.map((t) => `* ${t.name}: ${t.description}`).join('\n') + + '\n\n', + inputSchema: modernizeInputSchema.shape, + outputSchema: { + instructions: z + .array(z.string()) + .optional() + .describe('A list of instructions on how to run the migrations.'), + }, + isLocalOnly: true, + isReadOnly: true, + factory: () => (input) => runModernization(input), +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index 08ebdf46174b..dc71f6d5fd2b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -6,98 +6,89 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import path from 'node:path'; import z from 'zod'; -import type { AngularWorkspace } from '../../../utilities/config'; +import { McpToolContext, declareTool } from './tool-registry'; -export function registerListProjectsTool( - server: McpServer, - context: { - workspace?: AngularWorkspace; +export const LIST_PROJECTS_TOOL = declareTool({ + name: 'list_projects', + title: 'List Angular Projects', + description: + 'Lists the names of all applications and libraries defined within an Angular workspace. ' + + 'It reads the `angular.json` configuration file to identify the projects. ', + outputSchema: { + projects: z.array( + z.object({ + name: z + .string() + .describe('The name of the project, as defined in the `angular.json` file.'), + type: z + .enum(['application', 'library']) + .optional() + .describe(`The type of the project, either 'application' or 'library'.`), + root: z + .string() + .describe('The root directory of the project, relative to the workspace root.'), + sourceRoot: z + .string() + .describe( + `The root directory of the project's source files, relative to the workspace root.`, + ), + selectorPrefix: z + .string() + .optional() + .describe( + 'The prefix to use for component selectors.' + + ` For example, a prefix of 'app' would result in selectors like ''.`, + ), + }), + ), }, -): void { - server.registerTool( - 'list_projects', - { - title: 'List Angular Projects', - description: - 'Lists the names of all applications and libraries defined within an Angular workspace. ' + - 'It reads the `angular.json` configuration file to identify the projects. ', - annotations: { - readOnlyHint: true, - openWorldHint: false, - }, - outputSchema: { - projects: z.array( - z.object({ - name: z - .string() - .describe('The name of the project, as defined in the `angular.json` file.'), - type: z - .enum(['application', 'library']) - .optional() - .describe(`The type of the project, either 'application' or 'library'.`), - root: z - .string() - .describe('The root directory of the project, relative to the workspace root.'), - sourceRoot: z - .string() - .describe( - `The root directory of the project's source files, relative to the workspace root.`, - ), - selectorPrefix: z - .string() - .optional() - .describe( - 'The prefix to use for component selectors.' + - ` For example, a prefix of 'app' would result in selectors like ''.`, - ), - }), - ), - }, - }, - async () => { - const { workspace } = context; + isReadOnly: true, + isLocalOnly: true, + shouldRegister: (context) => !!context.workspace, + factory: createListProjectsHandler, +}); - if (!workspace) { - return { - content: [ - { - type: 'text' as const, - text: - 'No Angular workspace found.' + - ' An `angular.json` file, which marks the root of a workspace,' + - ' could not be located in the current directory or any of its parent directories.', - }, - ], - structuredContent: { projects: [] }, - }; - } - - const projects = []; - // Convert to output format - for (const [name, project] of workspace.projects.entries()) { - projects.push({ - name, - type: project.extensions['projectType'] as 'application' | 'library' | undefined, - root: project.root, - sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'), - selectorPrefix: project.extensions['prefix'] as string, - }); - } - - // The structuredContent field is newer and may not be supported by all hosts. - // A text representation of the content is also provided for compatibility. +function createListProjectsHandler({ workspace }: McpToolContext) { + return async () => { + if (!workspace) { return { content: [ { type: 'text' as const, - text: `Projects in the Angular workspace:\n${JSON.stringify(projects)}`, + text: + 'No Angular workspace found.' + + ' An `angular.json` file, which marks the root of a workspace,' + + ' could not be located in the current directory or any of its parent directories.', }, ], - structuredContent: { projects }, + structuredContent: { projects: [] }, }; - }, - ); + } + + const projects = []; + // Convert to output format + for (const [name, project] of workspace.projects.entries()) { + projects.push({ + name, + type: project.extensions['projectType'] as 'application' | 'library' | undefined, + root: project.root, + sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'), + selectorPrefix: project.extensions['prefix'] as string, + }); + } + + // The structuredContent field is newer and may not be supported by all hosts. + // A text representation of the content is also provided for compatibility. + return { + content: [ + { + type: 'text' as const, + text: `Projects in the Angular workspace:\n${JSON.stringify(projects)}`, + }, + ], + structuredContent: { projects }, + }; + }; } diff --git a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts new file mode 100644 index 000000000000..d29f0b125827 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts @@ -0,0 +1,71 @@ +/** + * @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, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ZodRawShape } from 'zod'; +import type { AngularWorkspace } from '../../../utilities/config'; + +type ToolConfig = Parameters[1]; + +export interface McpToolContext { + workspace?: AngularWorkspace; + logger: { warn(text: string): void }; + exampleDatabasePath?: string; +} + +export type McpToolFactory = ( + context: McpToolContext, +) => ToolCallback | Promise>; + +export interface McpToolDeclaration { + name: string; + title?: string; + description: string; + annotations?: ToolConfig['annotations']; + inputSchema?: TInput; + outputSchema?: TOutput; + factory: McpToolFactory; + shouldRegister?: (context: McpToolContext) => boolean | Promise; + isReadOnly?: boolean; + isLocalOnly?: boolean; +} + +export function declareTool( + declaration: McpToolDeclaration, +): McpToolDeclaration { + return declaration; +} + +export async function registerTools( + server: McpServer, + context: McpToolContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + declarations: McpToolDeclaration[], +): Promise { + for (const declaration of declarations) { + if (declaration.shouldRegister && !(await declaration.shouldRegister(context))) { + continue; + } + + const { name, factory, shouldRegister, isReadOnly, isLocalOnly, ...config } = declaration; + + const handler = await factory(context); + + // Add declarative characteristics to annotations + config.annotations ??= {}; + if (isReadOnly !== undefined) { + config.annotations.readOnlyHint = isReadOnly; + } + if (isLocalOnly !== undefined) { + // openWorldHint: false means local only + config.annotations.openWorldHint = !isLocalOnly; + } + + server.registerTool(name, config, handler); + } +} From 7aa5ef290ef3e876a9e4ae237bdba5e5225f65ac Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:44:29 -0400 Subject: [PATCH 2/5] feat(@angular/cli): add --read-only option to mcp command This change introduces a `--read-only` flag to the `ng mcp` command. When this flag is present, the MCP server will only register tools that are explicitly marked as read-only. This provides a way for host applications to connect to the Angular CLI MCP server with a restricted set of capabilities, ensuring that no tools capable of modifying the user's workspace are exposed. --- packages/angular/cli/src/commands/mcp/cli.ts | 10 +++++++--- packages/angular/cli/src/commands/mcp/mcp-server.ts | 7 ++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index b86de626f9c1..f166e2f70219 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -33,10 +33,14 @@ export default class McpCommandModule extends CommandModule implements CommandMo longDescriptionPath = undefined; builder(localYargs: Argv): Argv { - return localYargs; + return localYargs.option('read-only', { + type: 'boolean', + default: false, + describe: 'Only register read-only tools.', + }); } - async run(): Promise { + async run(options: { readOnly: boolean }): Promise { if (isTTY()) { this.context.logger.info(INTERACTIVE_MESSAGE); @@ -44,7 +48,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo } const server = await createMcpServer( - { workspace: this.context.workspace }, + { workspace: this.context.workspace, readOnly: options.readOnly }, this.context.logger, ); const transport = new StdioServerTransport(); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 5234b44723ac..aff83f4ef494 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -21,6 +21,7 @@ import { registerTools } from './tools/tool-registry'; export async function createMcpServer( context: { workspace?: AngularWorkspace; + readOnly?: boolean; }, logger: { warn(text: string): void }, ): Promise { @@ -43,7 +44,7 @@ export async function createMcpServer( registerInstructionsResource(server); - const toolDeclarations = [ + let toolDeclarations = [ BEST_PRACTICES_TOOL, DOC_SEARCH_TOOL, LIST_PROJECTS_TOOL, @@ -51,6 +52,10 @@ export async function createMcpServer( FIND_EXAMPLE_TOOL, ]; + if (context.readOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); + } + await registerTools( server, { From 09d74a7d0897959751c7830140d92d41406970ef Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:58:09 -0400 Subject: [PATCH 3/5] feat(@angular/cli): add --local-only option to mcp command This change introduces a `--local-only` flag to the `ng mcp` command. When this flag is present, the MCP server will only register tools that are explicitly marked as `isLocalOnly`. This provides a way for host applications to connect to the Angular CLI MCP server in an offline or restricted environment, ensuring that no tools that require internet access are exposed. --- packages/angular/cli/src/commands/mcp/cli.ts | 24 +++++++++++++------ .../cli/src/commands/mcp/mcp-server.ts | 5 ++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index f166e2f70219..c851c5592f3e 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -33,14 +33,20 @@ export default class McpCommandModule extends CommandModule implements CommandMo longDescriptionPath = undefined; builder(localYargs: Argv): Argv { - return localYargs.option('read-only', { - type: 'boolean', - default: false, - describe: 'Only register read-only tools.', - }); + return localYargs + .option('read-only', { + type: 'boolean', + default: false, + describe: 'Only register read-only tools.', + }) + .option('local-only', { + type: 'boolean', + default: false, + describe: 'Only register tools that do not require internet access.', + }); } - async run(options: { readOnly: boolean }): Promise { + async run(options: { readOnly: boolean; localOnly: boolean }): Promise { if (isTTY()) { this.context.logger.info(INTERACTIVE_MESSAGE); @@ -48,7 +54,11 @@ export default class McpCommandModule extends CommandModule implements CommandMo } const server = await createMcpServer( - { workspace: this.context.workspace, readOnly: options.readOnly }, + { + workspace: this.context.workspace, + readOnly: options.readOnly, + localOnly: options.localOnly, + }, this.context.logger, ); const transport = new StdioServerTransport(); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index aff83f4ef494..e36e2bef5121 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -22,6 +22,7 @@ export async function createMcpServer( context: { workspace?: AngularWorkspace; readOnly?: boolean; + localOnly?: boolean; }, logger: { warn(text: string): void }, ): Promise { @@ -56,6 +57,10 @@ export async function createMcpServer( toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); } + if (context.localOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isLocalOnly); + } + await registerTools( server, { From 7f989020f44c42a04b684d5d6ffea65d7bea9983 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:27:48 -0400 Subject: [PATCH 4/5] feat(@angular/cli): add --experimental-tool option to mcp command This change introduces an `--experimental-tool` flag (with a `-E` alias) to the `ng mcp` command. This flag allows users to enable specific experimental tools by providing their names. Experimental tools are kept separate from the main toolset and are only registered if explicitly enabled via this option. This provides a mechanism for safely developing and testing new tools without exposing them to all users by default. --- packages/angular/cli/src/commands/mcp/cli.ts | 13 +++++++++++- .../cli/src/commands/mcp/mcp-server.ts | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index c851c5592f3e..7e3618eeb17e 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -43,10 +43,20 @@ export default class McpCommandModule extends CommandModule implements CommandMo type: 'boolean', default: false, describe: 'Only register tools that do not require internet access.', + }) + .option('experimental-tool', { + type: 'string', + alias: 'E', + array: true, + describe: 'Enable an experimental tool.', }); } - async run(options: { readOnly: boolean; localOnly: boolean }): Promise { + async run(options: { + readOnly: boolean; + localOnly: boolean; + experimentalTool: string[] | undefined; + }): Promise { if (isTTY()) { this.context.logger.info(INTERACTIVE_MESSAGE); @@ -58,6 +68,7 @@ export default class McpCommandModule extends CommandModule implements CommandMo workspace: this.context.workspace, readOnly: options.readOnly, localOnly: options.localOnly, + experimentalTools: options.experimentalTool, }, this.context.logger, ); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index e36e2bef5121..03e55a87d159 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -16,13 +16,14 @@ import { DOC_SEARCH_TOOL } from './tools/doc-search'; import { FIND_EXAMPLE_TOOL } from './tools/examples'; import { MODERNIZE_TOOL } from './tools/modernize'; import { LIST_PROJECTS_TOOL } from './tools/projects'; -import { registerTools } from './tools/tool-registry'; +import { McpToolDeclaration, registerTools } from './tools/tool-registry'; export async function createMcpServer( context: { workspace?: AngularWorkspace; readOnly?: boolean; localOnly?: boolean; + experimentalTools?: string[]; }, logger: { warn(text: string): void }, ): Promise { @@ -53,6 +54,9 @@ export async function createMcpServer( FIND_EXAMPLE_TOOL, ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const experimentalToolDeclarations: McpToolDeclaration[] = []; + if (context.readOnly) { toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); } @@ -61,6 +65,21 @@ export async function createMcpServer( toolDeclarations = toolDeclarations.filter((tool) => tool.isLocalOnly); } + if (context.experimentalTools?.length) { + const experimentalToolsMap = new Map( + experimentalToolDeclarations.map((tool) => [tool.name, tool]), + ); + + for (const toolName of context.experimentalTools) { + const tool = experimentalToolsMap.get(toolName); + if (tool) { + toolDeclarations.push(tool); + } else { + logger.warn(`Unknown experimental tool: ${toolName}`); + } + } + } + await registerTools( server, { From 8869cb811ee536a77ba1c5708c483e08f3c16614 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:50:14 -0400 Subject: [PATCH 5/5] refactor(@angular/cli): move find_examples and modernize to experimental This change moves the `find_examples` and `modernize` tools to the experimental toolset. They can now be enabled by passing the `--experimental-tool ` flag to the `ng mcp` command. For backward compatibility, the `find_examples` tool will also be enabled if the `NG_MCP_CODE_EXAMPLES=1` environment variable is set. --- .../angular/cli/src/commands/mcp/mcp-server.ts | 18 ++++++++++-------- .../cli/src/commands/mcp/tools/examples.ts | 4 ---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 03e55a87d159..da8d712319c4 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -46,16 +46,13 @@ export async function createMcpServer( registerInstructionsResource(server); - let toolDeclarations = [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let toolDeclarations: McpToolDeclaration[] = [ BEST_PRACTICES_TOOL, DOC_SEARCH_TOOL, LIST_PROJECTS_TOOL, - MODERNIZE_TOOL, - FIND_EXAMPLE_TOOL, ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const experimentalToolDeclarations: McpToolDeclaration[] = []; + const experimentalToolDeclarations = [FIND_EXAMPLE_TOOL, MODERNIZE_TOOL]; if (context.readOnly) { toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); @@ -65,12 +62,17 @@ export async function createMcpServer( toolDeclarations = toolDeclarations.filter((tool) => tool.isLocalOnly); } - if (context.experimentalTools?.length) { + const enabledExperimentalTools = new Set(context.experimentalTools); + if (process.env['NG_MCP_CODE_EXAMPLES'] === '1') { + enabledExperimentalTools.add('find_examples'); + } + + if (enabledExperimentalTools.size > 0) { const experimentalToolsMap = new Map( experimentalToolDeclarations.map((tool) => [tool.name, tool]), ); - for (const toolName of context.experimentalTools) { + for (const toolName of enabledExperimentalTools) { const tool = experimentalToolsMap.get(toolName); if (tool) { toolDeclarations.push(tool); diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 79861d58f681..0690be04f523 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -55,10 +55,6 @@ export const FIND_EXAMPLE_TOOL = declareTool({ isReadOnly: true, isLocalOnly: true, shouldRegister: ({ logger }) => { - if (process.env['NG_MCP_CODE_EXAMPLES'] !== '1') { - return false; - } - // sqlite database support requires Node.js 22.16+ const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) {