Skip to content
Merged
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
43 changes: 42 additions & 1 deletion packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
import { existsSync as nodeExistsSync } from 'fs';
import { ChildProcess, spawn } from 'node:child_process';
import { Stats } from 'node:fs';
import { stat } from 'node:fs/promises';
import { glob as nodeGlob, readFile as nodeReadFile, stat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { createServer } from 'node:net';

/**
Expand Down Expand Up @@ -50,6 +51,33 @@ export interface Host {
*/
existsSync(path: string): boolean;

/**
* Reads a file and returns its content.
* @param path The path to the file.
* @param encoding The encoding to use.
* @returns A promise that resolves to the file content.
*/
readFile(path: string, encoding: 'utf-8'): Promise<string>;

/**
* Finds files matching a glob pattern.
* @param pattern The glob pattern.
* @param options Options for the glob search.
* @returns An async iterable of file entries.
*/
glob(
pattern: string,
options: { cwd: string },
): AsyncIterable<{ name: string; parentPath: string; isFile(): boolean }>;

/**
* Resolves a module request from a given path.
* @param request The module request to resolve.
* @param from The path from which to resolve the request.
* @returns The resolved module path.
*/
resolveModule(request: string, from: string): string;

/**
* Spawns a child process and returns a promise that resolves with the process's
* output or rejects with a structured error.
Expand Down Expand Up @@ -100,6 +128,19 @@ export const LocalWorkspaceHost: Host = {

existsSync: nodeExistsSync,

readFile: nodeReadFile,

glob: function (
pattern: string,
options: { cwd: string },
): AsyncIterable<{ name: string; parentPath: string; isFile(): boolean }> {
return nodeGlob(pattern, { ...options, withFileTypes: true });
},

resolveModule(request: string, from: string): string {
return createRequire(from).resolve(request);
},

runCommand: async (
command: string,
args: readonly string[],
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { join } from 'node:path';
import type { AngularWorkspace } from '../../utilities/config';
import { VERSION } from '../../utilities/version';
import type { DevServer } from './dev-server';
import { LocalWorkspaceHost } from './host';
import { registerInstructionsResource } from './resources/instructions';
import { AI_TUTOR_TOOL } from './tools/ai-tutor';
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
Expand Down Expand Up @@ -115,6 +116,7 @@ equivalent actions.
logger,
exampleDatabasePath: join(__dirname, '../../../lib/code-examples.db'),
devServers: new Map<string, DevServer>(),
host: LocalWorkspaceHost,
},
toolDeclarations,
);
Expand Down
5 changes: 4 additions & 1 deletion packages/angular/cli/src/commands/mcp/testing/mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import type { Host } from '../host';
* This class allows spying on host methods and controlling their return values.
*/
export class MockHost implements Host {
runCommand = jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' });
runCommand = jasmine.createSpy('runCommand').and.resolveTo({ logs: [] });
stat = jasmine.createSpy('stat');
existsSync = jasmine.createSpy('existsSync');
readFile = jasmine.createSpy('readFile').and.resolveTo('');
glob = jasmine.createSpy('glob').and.returnValue((async function* () {})());
resolveModule = jasmine.createSpy('resolveRequest').and.returnValue('/dev/null');
spawn = jasmine.createSpy('spawn');
getAvailablePort = jasmine.createSpy('getAvailablePort');
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { readFile, stat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import type { McpToolContext } from '../tool-registry';

Expand Down Expand Up @@ -36,28 +34,29 @@ const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/form
*
* @param workspacePath The absolute path to the user's `angular.json` file.
* @param logger The MCP tool context logger for reporting warnings.
* @param host The host interface for file system and module resolution operations.
* @returns A promise that resolves to an array of objects, each containing a database path and source.
*/
export async function getVersionSpecificExampleDatabases(
workspacePath: string,
logger: McpToolContext['logger'],
host: McpToolContext['host'],
): Promise<{ dbPath: string; source: string }[]> {
const workspaceRequire = createRequire(workspacePath);
const databases: { dbPath: string; source: string }[] = [];

for (const packageName of KNOWN_EXAMPLE_PACKAGES) {
// 1. Resolve the path to package.json
let pkgJsonPath: string;
try {
pkgJsonPath = workspaceRequire.resolve(`${packageName}/package.json`);
pkgJsonPath = host.resolveModule(`${packageName}/package.json`, workspacePath);
} catch (e) {
// This is not a warning because the user may not have all known packages installed.
continue;
}

// 2. Read and parse package.json, then find the database.
try {
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
const pkgJsonContent = await host.readFile(pkgJsonPath, 'utf-8');
const pkgJson = JSON.parse(pkgJsonContent);
const examplesInfo = pkgJson['angular']?.examples;

Expand All @@ -81,7 +80,7 @@ export async function getVersionSpecificExampleDatabases(
}

// Check the file size to prevent reading a very large file.
const stats = await stat(dbPath);
const stats = await host.stat(dbPath);
if (stats.size > 10 * 1024 * 1024) {
// 10MB
logger.warn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @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 { Stats } from 'node:fs';
import { Host } from '../../host';
import { getVersionSpecificExampleDatabases } from './database-discovery';

describe('getVersionSpecificExampleDatabases', () => {
let mockHost: jasmine.SpyObj<Host>;
let mockLogger: { warn: jasmine.Spy };

beforeEach(() => {
mockHost = jasmine.createSpyObj('Host', ['resolveModule', 'readFile', 'stat']);
mockLogger = {
warn: jasmine.createSpy('warn'),
};
});

it('should find a valid example database from a package', async () => {
mockHost.resolveModule.and.callFake((specifier) => {
if (specifier === '@angular/core/package.json') {
return '/path/to/node_modules/@angular/core/package.json';
}
throw new Error(`Unexpected module specifier: ${specifier}`);
});
mockHost.readFile.and.resolveTo(
JSON.stringify({
name: '@angular/core',
version: '18.1.0',
angular: {
examples: {
format: 'sqlite',
path: './resources/code-examples.db',
},
},
}),
);
mockHost.stat.and.resolveTo({ size: 1024 } as Stats);

const databases = await getVersionSpecificExampleDatabases(
'/path/to/workspace',
mockLogger,
mockHost,
);

expect(databases.length).toBe(1);
expect(databases[0].dbPath).toBe(
'/path/to/node_modules/@angular/core/resources/code-examples.db',
);
expect(databases[0].source).toBe('package @angular/core@18.1.0');
expect(mockLogger.warn).not.toHaveBeenCalled();
});

it('should skip packages without angular.examples metadata', async () => {
mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json');
mockHost.readFile.and.resolveTo(JSON.stringify({ name: '@angular/core', version: '18.1.0' }));

const databases = await getVersionSpecificExampleDatabases(
'/path/to/workspace',
mockLogger,
mockHost,
);

expect(databases.length).toBe(0);
});

it('should handle packages that are not found', async () => {
mockHost.resolveModule.and.throwError(new Error('Cannot find module'));

const databases = await getVersionSpecificExampleDatabases(
'/path/to/workspace',
mockLogger,
mockHost,
);

expect(databases.length).toBe(0);
expect(mockLogger.warn).not.toHaveBeenCalled();
});

it('should reject database paths that attempt path traversal', async () => {
mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json');
mockHost.readFile.and.resolveTo(
JSON.stringify({
name: '@angular/core',
version: '18.1.0',
angular: {
examples: {
format: 'sqlite',
path: '../outside-package/danger.db',
},
},
}),
);

const databases = await getVersionSpecificExampleDatabases(
'/path/to/workspace',
mockLogger,
mockHost,
);

expect(databases.length).toBe(0);
expect(mockLogger.warn).toHaveBeenCalledWith(
jasmine.stringMatching(/Detected a potential path traversal attempt/),
);
});

it('should skip database files larger than 10MB', async () => {
mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json');
mockHost.readFile.and.resolveTo(
JSON.stringify({
name: '@angular/core',
version: '18.1.0',
angular: {
examples: {
format: 'sqlite',
path: './resources/code-examples.db',
},
},
}),
);
mockHost.stat.and.resolveTo({ size: 11 * 1024 * 1024 } as Stats); // 11MB

const databases = await getVersionSpecificExampleDatabases(
'/path/to/workspace',
mockLogger,
mockHost,
);

expect(databases.length).toBe(0);
expect(mockLogger.warn).toHaveBeenCalledWith(jasmine.stringMatching(/is larger than 10MB/));
});
});
5 changes: 3 additions & 2 deletions packages/angular/cli/src/commands/mcp/tools/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ new or evolving features.
factory: createFindExampleHandler,
});

async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) {
async function createFindExampleHandler({ logger, exampleDatabasePath, host }: McpToolContext) {
const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR']
? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR'])
? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR'], host)
: undefined;

suppressSqliteWarning();
Expand All @@ -91,6 +91,7 @@ async function createFindExampleHandler({ logger, exampleDatabasePath }: McpTool
const versionSpecificDbs = await getVersionSpecificExampleDatabases(
input.workspacePath,
logger,
host,
);
for (const db of versionSpecificDbs) {
resolvedDbs.push({ path: db.dbPath, source: db.source });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { glob, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
import type { McpToolContext } from '../tool-registry';

/**
* A simple YAML front matter parser.
Expand Down Expand Up @@ -80,7 +80,10 @@ function parseFrontmatter(content: string): Record<string, unknown> {
return data;
}

export async function setupRuntimeExamples(examplesPath: string): Promise<DatabaseSync> {
export async function setupRuntimeExamples(
examplesPath: string,
host: McpToolContext['host'],
): Promise<DatabaseSync> {
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(':memory:');

Expand Down Expand Up @@ -156,12 +159,12 @@ export async function setupRuntimeExamples(examplesPath: string): Promise<Databa
});

db.exec('BEGIN TRANSACTION');
for await (const entry of glob('**/*.md', { cwd: examplesPath, withFileTypes: true })) {
for await (const entry of host.glob('**/*.md', { cwd: examplesPath })) {
if (!entry.isFile()) {
continue;
}

const content = await readFile(join(entry.parentPath, entry.name), 'utf-8');
const content = await host.readFile(join(entry.parentPath, entry.name), 'utf-8');
const frontmatter = parseFrontmatter(content);

const validation = frontmatterSchema.safeParse(frontmatter);
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types';
import type { ZodRawShape } from 'zod';
import type { AngularWorkspace } from '../../../utilities/config';
import type { DevServer } from '../dev-server';
import type { Host } from '../host';

export interface McpToolContext {
server: McpServer;
workspace?: AngularWorkspace;
logger: { warn(text: string): void };
exampleDatabasePath?: string;
devServers: Map<string, DevServer>;
host: Host;
}

export type McpToolFactory<TInput extends ZodRawShape> = (
Expand Down