diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts index b86de626f9c1..7e3618eeb17e 100644 --- a/packages/angular/cli/src/commands/mcp/cli.ts +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -33,10 +33,30 @@ 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.', + }) + .option('local-only', { + 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(): Promise { + async run(options: { + readOnly: boolean; + localOnly: boolean; + experimentalTool: string[] | undefined; + }): Promise { if (isTTY()) { this.context.logger.info(INTERACTIVE_MESSAGE); @@ -44,7 +64,12 @@ export default class McpCommandModule extends CommandModule implements CommandMo } const server = await createMcpServer( - { workspace: this.context.workspace }, + { + workspace: this.context.workspace, + readOnly: options.readOnly, + localOnly: options.localOnly, + experimentalTools: options.experimentalTool, + }, 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 18adce7478d1..da8d712319c4 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -7,20 +7,23 @@ */ 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 { 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 { @@ -42,28 +45,52 @@ 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let toolDeclarations: McpToolDeclaration[] = [ + BEST_PRACTICES_TOOL, + DOC_SEARCH_TOOL, + LIST_PROJECTS_TOOL, + ]; + const experimentalToolDeclarations = [FIND_EXAMPLE_TOOL, MODERNIZE_TOOL]; + + if (context.readOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); } - await registerDocSearchTool(server); + if (context.localOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isLocalOnly); + } + const enabledExperimentalTools = new Set(context.experimentalTools); 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')); + enabledExperimentalTools.add('find_examples'); + } + + if (enabledExperimentalTools.size > 0) { + const experimentalToolsMap = new Map( + experimentalToolDeclarations.map((tool) => [tool.name, tool]), + ); + + for (const toolName of enabledExperimentalTools) { + const tool = experimentalToolsMap.get(toolName); + if (tool) { + toolDeclarations.push(tool); + } else { + logger.warn(`Unknown experimental tool: ${toolName}`); + } } } + 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..0690be04f523 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,77 @@ 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 }) => { + // 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; + } - // 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 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(); + + 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); + } +}