Skip to content

refactor(@angular/cli): provide a find examples MCP server tool #30717

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"@types/less": "^3.0.3",
"@types/loader-utils": "^2.0.0",
"@types/lodash": "^4.17.0",
"@types/node": "^20.19.7",
"@types/node": "^22.12.0",
"@types/npm-package-arg": "^6.1.0",
"@types/pacote": "^11.1.3",
"@types/picomatch": "^4.0.0",
Expand Down
13 changes: 13 additions & 0 deletions packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

load("@npm//:defs.bzl", "npm_link_all_packages")
load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project")
load("//tools:example_db_generator.bzl", "cli_example_db")
load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema")
load("//tools:ts_json_schema.bzl", "ts_json_schema")

Expand All @@ -25,6 +26,7 @@ RUNTIME_ASSETS = glob(
],
) + [
"//packages/angular/cli:lib/config/schema.json",
"//packages/angular/cli:lib/code-examples.db",
]

ts_project(
Expand Down Expand Up @@ -74,6 +76,17 @@ ts_project(
],
)

cli_example_db(
name = "cli_example_database",
srcs = glob(
include = [
"lib/examples/**/*.md",
],
),
out = "lib/code-examples.db",
path = "packages/angular/cli/lib/examples",
)

CLI_SCHEMA_DATA = [
"//packages/angular/build:schemas",
"//packages/angular_devkit/build_angular:schemas",
Expand Down
28 changes: 28 additions & 0 deletions packages/angular/cli/lib/examples/if-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Angular @if Control Flow Example

This example demonstrates how to use the `@if` control flow block in an Angular template. The visibility of a `<div>` element is controlled by a boolean field in the component's TypeScript code.

## Angular Template

```html
<!-- The @if directive will only render this div if the 'isVisible' field in the component is true. -->
@if (isVisible) {
<div>This content is conditionally displayed.</div>
}
```

## Component TypeScript

```typescript
import { Component } from '@angular/core';

@Component({
selector: 'app-example',
templateUrl: './example.component.html',
styleUrls: ['./example.component.css'],
})
export class ExampleComponent {
// This boolean field controls the visibility of the element in the template.
isVisible: boolean = true;
}
```
5 changes: 4 additions & 1 deletion packages/angular/cli/src/commands/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export default class McpCommandModule extends CommandModule implements CommandMo
return;
}

const server = await createMcpServer({ workspace: this.context.workspace });
const server = await createMcpServer(
{ workspace: this.context.workspace },
this.context.logger,
);
const transport = new StdioServerTransport();
await server.connect(transport);
}
Expand Down
21 changes: 18 additions & 3 deletions packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ import { z } from 'zod';
import type { AngularWorkspace } from '../../utilities/config';
import { VERSION } from '../../utilities/version';
import { registerDocSearchTool } from './tools/doc-search';
import { registerFindExampleTool } from './tools/examples';

export async function createMcpServer(context: {
workspace?: AngularWorkspace;
}): Promise<McpServer> {
export async function createMcpServer(
context: {
workspace?: AngularWorkspace;
},
logger: { warn(text: string): void },
): Promise<McpServer> {
const server = new McpServer({
name: 'angular-cli-server',
version: VERSION.full,
Expand Down Expand Up @@ -132,5 +136,16 @@ export async function createMcpServer(context: {

await registerDocSearchTool(server);

// 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'));
}

return server;
}
176 changes: 176 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @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 { z } from 'zod';

/**
* 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<void> {
let db: import('node:sqlite').DatabaseSync | undefined;
let queryStatement: import('node:sqlite').StatementSync | undefined;

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.
Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation):
- AND (default): Space-separated terms are combined with AND.
- Example: 'standalone component' (finds results with both "standalone" and "component")
- OR: Use the OR operator to find results with either term.
- Example: 'validation OR validator'
- NOT: Use the NOT operator to exclude terms.
- Example: 'forms NOT reactive'
- Grouping: Use parentheses () to group expressions.
- Example: '(validation OR validator) AND forms'
- Phrase Search: Use double quotes "" for exact phrases.
- Example: '"template-driven forms"'
- Prefix Search: Use an asterisk * for prefix matching.
- Example: 'rout*' (matches "route", "router", "routing")
Examples of queries:
- Find standalone components: 'standalone component'
- Find ngFor with trackBy: 'ngFor trackBy'
- 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 || !queryStatement) {
suppressSqliteWarning();

const { DatabaseSync } = await import('node:sqlite');
db = new DatabaseSync(exampleDatabasePath, { readOnly: true });
queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;');
}

const sanitizedQuery = sanitizeSearchQuery(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,
};
},
);
}

/**
* Sanitizes a search query for FTS5 by tokenizing and quoting terms.
*
* This function processes a raw search string and prepares it for an FTS5 full-text search.
* It correctly handles quoted phrases, logical operators (AND, OR, NOT), parentheses,
* and prefix searches (ending with an asterisk), ensuring that individual search
* terms are properly quoted to be treated as literals by the search engine.
*
* @param query The raw search query string.
* @returns A sanitized query string suitable for FTS5.
*/
export function sanitizeSearchQuery(query: string): string {
// This regex tokenizes the query string into parts:
// 1. Quoted phrases (e.g., "foo bar")
// 2. Parentheses ( and )
// 3. FTS5 operators (AND, OR, NOT, NEAR)
// 4. Words, which can include a trailing asterisk for prefix search (e.g., foo*)
const tokenizer = /"([^"]*)"|([()])|\b(AND|OR|NOT|NEAR)\b|([^\s()]+)/g;
let match;
const result: string[] = [];
let lastIndex = 0;

while ((match = tokenizer.exec(query)) !== null) {
// Add any whitespace or other characters between tokens
if (match.index > lastIndex) {
result.push(query.substring(lastIndex, match.index));
}

const [, quoted, parenthesis, operator, term] = match;

if (quoted !== undefined) {
// It's a quoted phrase, keep it as is.
result.push(`"${quoted}"`);
} else if (parenthesis) {
// It's a parenthesis, keep it as is.
result.push(parenthesis);
} else if (operator) {
// It's an operator, keep it as is.
result.push(operator);
} else if (term) {
// It's a term that needs to be quoted.
if (term.endsWith('*')) {
result.push(`"${term.slice(0, -1)}"*`);
} else {
result.push(`"${term}"`);
}
}
lastIndex = tokenizer.lastIndex;
}

// Add any remaining part of the string
if (lastIndex < query.length) {
result.push(query.substring(lastIndex));
}

return result.join('');
}

/**
* Suppresses the experimental warning emitted by Node.js for the `node:sqlite` module.
*
* This is a workaround to prevent the console from being cluttered with warnings
* about the experimental status of the SQLite module, which is used by this tool.
*/
function suppressSqliteWarning() {
const originalProcessEmit = process.emit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.emit = function (event: string, error?: unknown): any {
if (
event === 'warning' &&
error instanceof Error &&
error.name === 'ExperimentalWarning' &&
error.message.includes('SQLite')
) {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-rest-params
return originalProcessEmit.apply(process, arguments as any);
};
}
53 changes: 53 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/examples_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @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 { sanitizeSearchQuery } from './examples';

describe('sanitizeSearchQuery', () => {
it('should wrap single terms in double quotes', () => {
expect(sanitizeSearchQuery('foo')).toBe('"foo"');
});

it('should wrap multiple terms in double quotes', () => {
expect(sanitizeSearchQuery('foo bar')).toBe('"foo" "bar"');
});

it('should not wrap FTS5 operators', () => {
expect(sanitizeSearchQuery('foo AND bar')).toBe('"foo" AND "bar"');
expect(sanitizeSearchQuery('foo OR bar')).toBe('"foo" OR "bar"');
expect(sanitizeSearchQuery('foo NOT bar')).toBe('"foo" NOT "bar"');
expect(sanitizeSearchQuery('foo NEAR bar')).toBe('"foo" NEAR "bar"');
});

it('should not wrap terms that are already quoted', () => {
expect(sanitizeSearchQuery('"foo" bar')).toBe('"foo" "bar"');
expect(sanitizeSearchQuery('"foo bar"')).toBe('"foo bar"');
});

it('should handle prefix searches', () => {
expect(sanitizeSearchQuery('foo*')).toBe('"foo"*');
expect(sanitizeSearchQuery('foo* bar')).toBe('"foo"* "bar"');
});

it('should handle multi-word quoted phrases', () => {
expect(sanitizeSearchQuery('"foo bar" baz')).toBe('"foo bar" "baz"');
expect(sanitizeSearchQuery('foo "bar baz"')).toBe('"foo" "bar baz"');
});

it('should handle complex queries', () => {
expect(sanitizeSearchQuery('("foo bar" OR baz) AND qux*')).toBe(
'("foo bar" OR "baz") AND "qux"*',
);
});

it('should handle multi-word quoted phrases with three or more words', () => {
expect(sanitizeSearchQuery('"foo bar baz" qux')).toBe('"foo bar baz" "qux"');
expect(sanitizeSearchQuery('foo "bar baz qux"')).toBe('"foo" "bar baz qux"');
expect(sanitizeSearchQuery('foo "bar baz qux" quux')).toBe('"foo" "bar baz qux" "quux"');
});
});
Loading