Skip to content

Commit 708e944

Browse files
committed
refactor(@angular/cli): provide a find examples MCP server tool
The built-in stdio MCP server (`ng mcp`) for the Angular CLI now includes a tool that can find Angular code usage examples. The code examples are indexed and stored locally within the Angular CLI install. This removes the need for network requests to find the examples. It also ensures that the examples are relevant for the version of Angular currently being used. This tool requires Node.js 22.16 or higher. Lower versions will not have this specific tool available for use.
1 parent 16ae6a1 commit 708e944

File tree

11 files changed

+508
-141
lines changed

11 files changed

+508
-141
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"@types/less": "^3.0.3",
8383
"@types/loader-utils": "^2.0.0",
8484
"@types/lodash": "^4.17.0",
85-
"@types/node": "^20.19.7",
85+
"@types/node": "^22.12.0",
8686
"@types/npm-package-arg": "^6.1.0",
8787
"@types/pacote": "^11.1.3",
8888
"@types/picomatch": "^4.0.0",

packages/angular/cli/BUILD.bazel

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
load("@npm//:defs.bzl", "npm_link_all_packages")
77
load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project")
8+
load("//tools:example_db_generator.bzl", "cli_example_db")
89
load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema")
910
load("//tools:ts_json_schema.bzl", "ts_json_schema")
1011

@@ -25,6 +26,7 @@ RUNTIME_ASSETS = glob(
2526
],
2627
) + [
2728
"//packages/angular/cli:lib/config/schema.json",
29+
"//packages/angular/cli:lib/code-examples.db",
2830
]
2931

3032
ts_project(
@@ -74,6 +76,17 @@ ts_project(
7476
],
7577
)
7678

79+
cli_example_db(
80+
name = "cli_example_database",
81+
srcs = glob(
82+
include = [
83+
"lib/examples/**/*.md",
84+
],
85+
),
86+
out = "lib/code-examples.db",
87+
path = "packages/angular/cli/lib/examples",
88+
)
89+
7790
CLI_SCHEMA_DATA = [
7891
"//packages/angular/build:schemas",
7992
"//packages/angular_devkit/build_angular:schemas",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Angular @if Control Flow Example
2+
3+
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.
4+
5+
## Angular Template
6+
7+
```html
8+
<!-- The @if directive will only render this div if the 'isVisible' field in the component is true. -->
9+
@if (isVisible) {
10+
<div>This content is conditionally displayed.</div>
11+
}
12+
```
13+
14+
## Component TypeScript
15+
16+
```typescript
17+
import { Component } from '@angular/core';
18+
19+
@Component({
20+
selector: 'app-example',
21+
templateUrl: './example.component.html',
22+
styleUrls: ['./example.component.css'],
23+
})
24+
export class ExampleComponent {
25+
// This boolean field controls the visibility of the element in the template.
26+
isVisible: boolean = true;
27+
}
28+
```

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export default class McpCommandModule extends CommandModule implements CommandMo
4343
return;
4444
}
4545

46-
const server = await createMcpServer({ workspace: this.context.workspace });
46+
const server = await createMcpServer(
47+
{ workspace: this.context.workspace },
48+
this.context.logger,
49+
);
4750
const transport = new StdioServerTransport();
4851
await server.connect(transport);
4952
}

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ import { z } from 'zod';
1313
import type { AngularWorkspace } from '../../utilities/config';
1414
import { VERSION } from '../../utilities/version';
1515
import { registerDocSearchTool } from './tools/doc-search';
16+
import { registerFindExampleTool } from './tools/examples';
1617

17-
export async function createMcpServer(context: {
18-
workspace?: AngularWorkspace;
19-
}): Promise<McpServer> {
18+
export async function createMcpServer(
19+
context: {
20+
workspace?: AngularWorkspace;
21+
},
22+
logger: { warn(text: string): void },
23+
): Promise<McpServer> {
2024
const server = new McpServer({
2125
name: 'angular-cli-server',
2226
version: VERSION.full,
@@ -132,5 +136,16 @@ export async function createMcpServer(context: {
132136

133137
await registerDocSearchTool(server);
134138

139+
// sqlite database support requires Node.js 22.16+
140+
const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number);
141+
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) {
142+
logger.warn(
143+
`MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` +
144+
' Registration of this tool has been skipped.',
145+
);
146+
} else {
147+
await registerFindExampleTool(server, path.join(__dirname, '../../../lib/code-examples.db'));
148+
}
149+
135150
return server;
136151
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10+
import { z } from 'zod';
11+
12+
/**
13+
* Registers the `find_examples` tool with the MCP server.
14+
*
15+
* This tool allows users to search for best-practice Angular code examples
16+
* from a local SQLite database.
17+
*
18+
* @param server The MCP server instance.
19+
* @param exampleDatabasePath The path to the SQLite database file containing the examples.
20+
*/
21+
export async function registerFindExampleTool(
22+
server: McpServer,
23+
exampleDatabasePath: string,
24+
): Promise<void> {
25+
let db: import('node:sqlite').DatabaseSync | undefined;
26+
let queryStatement: import('node:sqlite').StatementSync | undefined;
27+
28+
server.registerTool(
29+
'find_examples',
30+
{
31+
title: 'Find Angular Code Examples',
32+
description:
33+
'Before writing or modifying any Angular code including templates, ' +
34+
'**ALWAYS** use this tool to find current best-practice examples. ' +
35+
'This is critical for ensuring code quality and adherence to modern Angular standards. ' +
36+
'This tool searches a curated database of approved Angular code examples and returns the most relevant results for your query. ' +
37+
'Example Use Cases: ' +
38+
"1) Creating new components, directives, or services (e.g., query: 'standalone component' or 'signal input'). " +
39+
"2) Implementing core features (e.g., query: 'lazy load route', 'httpinterceptor', or 'route guard'). " +
40+
"3) Refactoring existing code to use modern patterns (e.g., query: 'ngfor trackby' or 'form validation').",
41+
inputSchema: {
42+
query: z.string().describe(
43+
`Performs a full-text search using FTS5 syntax. The query should target relevant Angular concepts.
44+
45+
Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation):
46+
- AND (default): Space-separated terms are combined with AND.
47+
- Example: 'standalone component' (finds results with both "standalone" and "component")
48+
- OR: Use the OR operator to find results with either term.
49+
- Example: 'validation OR validator'
50+
- NOT: Use the NOT operator to exclude terms.
51+
- Example: 'forms NOT reactive'
52+
- Grouping: Use parentheses () to group expressions.
53+
- Example: '(validation OR validator) AND forms'
54+
- Phrase Search: Use double quotes "" for exact phrases.
55+
- Example: '"template-driven forms"'
56+
- Prefix Search: Use an asterisk * for prefix matching.
57+
- Example: 'rout*' (matches "route", "router", "routing")
58+
59+
Examples of queries:
60+
- Find standalone components: 'standalone component'
61+
- Find ngFor with trackBy: 'ngFor trackBy'
62+
- Find signal inputs: 'signal input'
63+
- Find lazy loading a route: 'lazy load route'
64+
- Find forms with validation: 'form AND (validation OR validator)'`,
65+
),
66+
},
67+
},
68+
async ({ query }) => {
69+
if (!db || !queryStatement) {
70+
suppressSqliteWarning();
71+
72+
const { DatabaseSync } = await import('node:sqlite');
73+
db = new DatabaseSync(exampleDatabasePath, { readOnly: true });
74+
queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;');
75+
}
76+
77+
const sanitizedQuery = sanitizeSearchQuery(query);
78+
79+
// Query database and return results as text content
80+
const content = [];
81+
for (const exampleRecord of queryStatement.all(sanitizedQuery)) {
82+
content.push({ type: 'text' as const, text: exampleRecord['content'] as string });
83+
}
84+
85+
return {
86+
content,
87+
};
88+
},
89+
);
90+
}
91+
92+
/**
93+
* Sanitizes a search query for FTS5 by tokenizing and quoting terms.
94+
*
95+
* This function processes a raw search string and prepares it for an FTS5 full-text search.
96+
* It correctly handles quoted phrases, logical operators (AND, OR, NOT), parentheses,
97+
* and prefix searches (ending with an asterisk), ensuring that individual search
98+
* terms are properly quoted to be treated as literals by the search engine.
99+
*
100+
* @param query The raw search query string.
101+
* @returns A sanitized query string suitable for FTS5.
102+
*/
103+
export function sanitizeSearchQuery(query: string): string {
104+
// This regex tokenizes the query string into parts:
105+
// 1. Quoted phrases (e.g., "foo bar")
106+
// 2. Parentheses ( and )
107+
// 3. FTS5 operators (AND, OR, NOT, NEAR)
108+
// 4. Words, which can include a trailing asterisk for prefix search (e.g., foo*)
109+
const tokenizer = /"([^"]*)"|([()])|\b(AND|OR|NOT|NEAR)\b|([^\s()]+)/g;
110+
let match;
111+
const result: string[] = [];
112+
let lastIndex = 0;
113+
114+
while ((match = tokenizer.exec(query)) !== null) {
115+
// Add any whitespace or other characters between tokens
116+
if (match.index > lastIndex) {
117+
result.push(query.substring(lastIndex, match.index));
118+
}
119+
120+
const [, quoted, parenthesis, operator, term] = match;
121+
122+
if (quoted !== undefined) {
123+
// It's a quoted phrase, keep it as is.
124+
result.push(`"${quoted}"`);
125+
} else if (parenthesis) {
126+
// It's a parenthesis, keep it as is.
127+
result.push(parenthesis);
128+
} else if (operator) {
129+
// It's an operator, keep it as is.
130+
result.push(operator);
131+
} else if (term) {
132+
// It's a term that needs to be quoted.
133+
if (term.endsWith('*')) {
134+
result.push(`"${term.slice(0, -1)}"*`);
135+
} else {
136+
result.push(`"${term}"`);
137+
}
138+
}
139+
lastIndex = tokenizer.lastIndex;
140+
}
141+
142+
// Add any remaining part of the string
143+
if (lastIndex < query.length) {
144+
result.push(query.substring(lastIndex));
145+
}
146+
147+
return result.join('');
148+
}
149+
150+
/**
151+
* Suppresses the experimental warning emitted by Node.js for the `node:sqlite` module.
152+
*
153+
* This is a workaround to prevent the console from being cluttered with warnings
154+
* about the experimental status of the SQLite module, which is used by this tool.
155+
*/
156+
function suppressSqliteWarning() {
157+
const originalProcessEmit = process.emit;
158+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
159+
process.emit = function (event: string, error?: unknown): any {
160+
if (
161+
event === 'warning' &&
162+
error instanceof Error &&
163+
error.name === 'ExperimentalWarning' &&
164+
error.message.includes('SQLite')
165+
) {
166+
return false;
167+
}
168+
169+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-rest-params
170+
return originalProcessEmit.apply(process, arguments as any);
171+
};
172+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { sanitizeSearchQuery } from './examples';
10+
11+
describe('sanitizeSearchQuery', () => {
12+
it('should wrap single terms in double quotes', () => {
13+
expect(sanitizeSearchQuery('foo')).toBe('"foo"');
14+
});
15+
16+
it('should wrap multiple terms in double quotes', () => {
17+
expect(sanitizeSearchQuery('foo bar')).toBe('"foo" "bar"');
18+
});
19+
20+
it('should not wrap FTS5 operators', () => {
21+
expect(sanitizeSearchQuery('foo AND bar')).toBe('"foo" AND "bar"');
22+
expect(sanitizeSearchQuery('foo OR bar')).toBe('"foo" OR "bar"');
23+
expect(sanitizeSearchQuery('foo NOT bar')).toBe('"foo" NOT "bar"');
24+
expect(sanitizeSearchQuery('foo NEAR bar')).toBe('"foo" NEAR "bar"');
25+
});
26+
27+
it('should not wrap terms that are already quoted', () => {
28+
expect(sanitizeSearchQuery('"foo" bar')).toBe('"foo" "bar"');
29+
expect(sanitizeSearchQuery('"foo bar"')).toBe('"foo bar"');
30+
});
31+
32+
it('should handle prefix searches', () => {
33+
expect(sanitizeSearchQuery('foo*')).toBe('"foo"*');
34+
expect(sanitizeSearchQuery('foo* bar')).toBe('"foo"* "bar"');
35+
});
36+
37+
it('should handle multi-word quoted phrases', () => {
38+
expect(sanitizeSearchQuery('"foo bar" baz')).toBe('"foo bar" "baz"');
39+
expect(sanitizeSearchQuery('foo "bar baz"')).toBe('"foo" "bar baz"');
40+
});
41+
42+
it('should handle complex queries', () => {
43+
expect(sanitizeSearchQuery('("foo bar" OR baz) AND qux*')).toBe(
44+
'("foo bar" OR "baz") AND "qux"*',
45+
);
46+
});
47+
48+
it('should handle multi-word quoted phrases with three or more words', () => {
49+
expect(sanitizeSearchQuery('"foo bar baz" qux')).toBe('"foo bar baz" "qux"');
50+
expect(sanitizeSearchQuery('foo "bar baz qux"')).toBe('"foo" "bar baz qux"');
51+
expect(sanitizeSearchQuery('foo "bar baz qux" quux')).toBe('"foo" "bar baz qux" "quux"');
52+
});
53+
});

0 commit comments

Comments
 (0)