|
6 | 6 | * found in the LICENSE file at https://angular.dev/license |
7 | 7 | */ |
8 | 8 |
|
9 | | -import { glob, readFile } from 'node:fs/promises'; |
| 9 | +import { glob, readFile, stat } from 'node:fs/promises'; |
| 10 | +import { createRequire } from 'node:module'; |
10 | 11 | import path from 'node:path'; |
11 | 12 | import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; |
12 | 13 | import { z } from 'zod'; |
13 | 14 | import { McpToolContext, declareTool } from './tool-registry'; |
14 | 15 |
|
15 | 16 | const findExampleInputSchema = z.object({ |
| 17 | + workspacePath: z |
| 18 | + .string() |
| 19 | + .optional() |
| 20 | + .describe( |
| 21 | + 'The absolute path to the `angular.json` file for the workspace. This is used to find the ' + |
| 22 | + 'version-specific code examples that correspond to the installed version of the ' + |
| 23 | + 'Angular framework. You **MUST** get this path from the `list_projects` tool. If omitted, ' + |
| 24 | + 'the tool will search the generic code examples bundled with the CLI.', |
| 25 | + ), |
16 | 26 | query: z |
17 | 27 | .string() |
18 | 28 | .describe( |
@@ -153,6 +163,12 @@ new or evolving features. |
153 | 163 | (e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation']) |
154 | 164 | </Use Cases> |
155 | 165 | <Operational Notes> |
| 166 | +* **Project-Specific Use (Recommended):** For tasks inside a user's project, you **MUST** provide the |
| 167 | + \`workspacePath\` argument to get examples that match the project's Angular version. Get this |
| 168 | + path from \`list_projects\`. |
| 169 | +* **General Use:** If no project context is available (e.g., for general questions or learning), |
| 170 | + you can call the tool without the \`workspacePath\` argument. It will return the latest |
| 171 | + generic examples. |
156 | 172 | * **Tool Selection:** This database primarily contains examples for new and recently updated Angular |
157 | 173 | features. For established, core features, the main documentation (via the |
158 | 174 | \`search_documentation\` tool) may be a better source of information. |
@@ -183,103 +199,218 @@ new or evolving features. |
183 | 199 | factory: createFindExampleHandler, |
184 | 200 | }); |
185 | 201 |
|
186 | | -async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) { |
187 | | - let db: DatabaseSync | undefined; |
| 202 | +/** |
| 203 | + * Attempts to find a version-specific example database from the user's installed |
| 204 | + * version of `@angular/core`. It looks for a custom `angular` metadata property in the |
| 205 | + * framework's `package.json` to locate the database. |
| 206 | + * |
| 207 | + * @example A sample `package.json` `angular` field: |
| 208 | + * ```json |
| 209 | + * { |
| 210 | + * "angular": { |
| 211 | + * "examples": { |
| 212 | + * "format": "sqlite", |
| 213 | + * "path": "./resources/code-examples.db" |
| 214 | + * } |
| 215 | + * } |
| 216 | + * } |
| 217 | + * ``` |
| 218 | + * |
| 219 | + * @param workspacePath The absolute path to the user's `angular.json` file. |
| 220 | + * @param logger The MCP tool context logger for reporting warnings. |
| 221 | + * @returns A promise that resolves to an object containing the database path and source, |
| 222 | + * or `undefined` if the database could not be resolved. |
| 223 | + */ |
| 224 | +async function getVersionSpecificExampleDatabase( |
| 225 | + workspacePath: string, |
| 226 | + logger: McpToolContext['logger'], |
| 227 | +): Promise<{ dbPath: string; source: string } | undefined> { |
| 228 | + // 1. Resolve the path to package.json |
| 229 | + let pkgJsonPath: string; |
| 230 | + try { |
| 231 | + const workspaceRequire = createRequire(workspacePath); |
| 232 | + pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json'); |
| 233 | + } catch (e) { |
| 234 | + logger.warn( |
| 235 | + `Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` + |
| 236 | + 'Is Angular installed in this project? Falling back to the bundled examples.', |
| 237 | + ); |
188 | 238 |
|
189 | | - if (process.env['NG_MCP_EXAMPLES_DIR']) { |
190 | | - db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']); |
| 239 | + return undefined; |
191 | 240 | } |
192 | 241 |
|
193 | | - suppressSqliteWarning(); |
| 242 | + // 2. Read and parse package.json, then find the database. |
| 243 | + try { |
| 244 | + const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8'); |
| 245 | + const pkgJson = JSON.parse(pkgJsonContent); |
| 246 | + const examplesInfo = pkgJson['angular']?.examples; |
| 247 | + |
| 248 | + if (examplesInfo && examplesInfo.format === 'sqlite' && typeof examplesInfo.path === 'string') { |
| 249 | + const packageDirectory = path.dirname(pkgJsonPath); |
| 250 | + const dbPath = path.resolve(packageDirectory, examplesInfo.path); |
| 251 | + |
| 252 | + // Ensure the resolved database path is within the package boundary. |
| 253 | + const relativePath = path.relative(packageDirectory, dbPath); |
| 254 | + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { |
| 255 | + logger.warn( |
| 256 | + `Detected a potential path traversal attempt in '${pkgJsonPath}'. ` + |
| 257 | + `The path '${examplesInfo.path}' escapes the package boundary. ` + |
| 258 | + 'Falling back to the bundled examples.', |
| 259 | + ); |
| 260 | + |
| 261 | + return undefined; |
| 262 | + } |
194 | 263 |
|
195 | | - return async (input: FindExampleInput) => { |
196 | | - if (!db) { |
197 | | - if (!exampleDatabasePath) { |
198 | | - // This should be prevented by the registration logic in mcp-server.ts |
199 | | - throw new Error('Example database path is not available.'); |
| 264 | + // Check the file size to prevent reading a very large file. |
| 265 | + const stats = await stat(dbPath); |
| 266 | + if (stats.size > 10 * 1024 * 1024) { |
| 267 | + // 10MB |
| 268 | + logger.warn( |
| 269 | + `The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` + |
| 270 | + 'This is unexpected and the file will not be used. Falling back to the bundled examples.', |
| 271 | + ); |
| 272 | + |
| 273 | + return undefined; |
200 | 274 | } |
201 | | - const { DatabaseSync } = await import('node:sqlite'); |
202 | | - db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); |
| 275 | + |
| 276 | + const source = `framework version ${pkgJson.version}`; |
| 277 | + |
| 278 | + return { dbPath, source }; |
| 279 | + } else { |
| 280 | + logger.warn( |
| 281 | + `Did not find valid 'angular.examples' metadata in '${pkgJsonPath}'. ` + |
| 282 | + 'Falling back to the bundled examples.', |
| 283 | + ); |
203 | 284 | } |
| 285 | + } catch (e) { |
| 286 | + logger.warn( |
| 287 | + `Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${ |
| 288 | + e instanceof Error ? e.message : e |
| 289 | + }. Falling back to the bundled examples.`, |
| 290 | + ); |
| 291 | + } |
| 292 | + |
| 293 | + return undefined; |
| 294 | +} |
| 295 | + |
| 296 | +async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) { |
| 297 | + const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR'] |
| 298 | + ? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']) |
| 299 | + : undefined; |
204 | 300 |
|
205 | | - const { query, keywords, required_packages, related_concepts, includeExperimental } = input; |
206 | | - |
207 | | - // Build the query dynamically |
208 | | - const params: SQLInputValue[] = []; |
209 | | - let sql = |
210 | | - 'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' + |
211 | | - // The `snippet` function generates a contextual snippet of the matched text. |
212 | | - // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. |
213 | | - "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " + |
214 | | - 'FROM examples_fts'; |
215 | | - const whereClauses = []; |
216 | | - |
217 | | - // FTS query |
218 | | - if (query) { |
219 | | - whereClauses.push('examples_fts MATCH ?'); |
220 | | - params.push(escapeSearchQuery(query)); |
| 301 | + suppressSqliteWarning(); |
| 302 | + |
| 303 | + return async (input: FindExampleInput) => { |
| 304 | + // If the dev-time override is present, use it and bypass all other logic. |
| 305 | + if (runtimeDb) { |
| 306 | + return queryDatabase(runtimeDb, input); |
221 | 307 | } |
222 | 308 |
|
223 | | - // JSON array filters |
224 | | - const addJsonFilter = (column: string, values: string[] | undefined) => { |
225 | | - if (values?.length) { |
226 | | - for (const value of values) { |
227 | | - whereClauses.push(`${column} LIKE ?`); |
228 | | - params.push(`%"${value}"%`); |
229 | | - } |
230 | | - } |
231 | | - }; |
| 309 | + let dbPath: string | undefined; |
232 | 310 |
|
233 | | - addJsonFilter('keywords', keywords); |
234 | | - addJsonFilter('required_packages', required_packages); |
235 | | - addJsonFilter('related_concepts', related_concepts); |
| 311 | + // First, try to get the version-specific guide. |
| 312 | + if (input.workspacePath) { |
| 313 | + const versionSpecific = await getVersionSpecificExampleDatabase(input.workspacePath, logger); |
| 314 | + if (versionSpecific) { |
| 315 | + dbPath = versionSpecific.dbPath; |
| 316 | + } |
| 317 | + } |
236 | 318 |
|
237 | | - if (!includeExperimental) { |
238 | | - whereClauses.push('experimental = 0'); |
| 319 | + // If the version-specific guide was not found for any reason, fall back to the bundled version. |
| 320 | + if (!dbPath) { |
| 321 | + dbPath = exampleDatabasePath; |
239 | 322 | } |
240 | 323 |
|
241 | | - if (whereClauses.length > 0) { |
242 | | - sql += ` WHERE ${whereClauses.join(' AND ')}`; |
| 324 | + if (!dbPath) { |
| 325 | + // This should be prevented by the registration logic in mcp-server.ts |
| 326 | + throw new Error('Example database path is not available.'); |
243 | 327 | } |
244 | 328 |
|
245 | | - // Order the results by relevance using the BM25 algorithm. |
246 | | - // The weights assigned to each column boost the ranking of documents where the |
247 | | - // search term appears in a more important field. |
248 | | - // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content |
249 | | - sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);'; |
250 | | - |
251 | | - const queryStatement = db.prepare(sql); |
252 | | - |
253 | | - // Query database and return results |
254 | | - const examples = []; |
255 | | - const textContent = []; |
256 | | - for (const exampleRecord of queryStatement.all(...params)) { |
257 | | - const record = exampleRecord as Record<string, string>; |
258 | | - const example = { |
259 | | - title: record['title'], |
260 | | - summary: record['summary'], |
261 | | - keywords: JSON.parse(record['keywords'] || '[]') as string[], |
262 | | - required_packages: JSON.parse(record['required_packages'] || '[]') as string[], |
263 | | - related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[], |
264 | | - related_tools: JSON.parse(record['related_tools'] || '[]') as string[], |
265 | | - content: record['content'], |
266 | | - snippet: record['snippet'], |
267 | | - }; |
268 | | - examples.push(example); |
269 | | - |
270 | | - // Also create a more structured text output |
271 | | - let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`; |
272 | | - if (example.snippet) { |
273 | | - text += `\n**Snippet:** ${example.snippet}`; |
| 329 | + const { DatabaseSync } = await import('node:sqlite'); |
| 330 | + const db = new DatabaseSync(dbPath, { readOnly: true }); |
| 331 | + |
| 332 | + return queryDatabase(db, input); |
| 333 | + }; |
| 334 | +} |
| 335 | + |
| 336 | +function queryDatabase(db: DatabaseSync, input: FindExampleInput) { |
| 337 | + const { query, keywords, required_packages, related_concepts, includeExperimental } = input; |
| 338 | + |
| 339 | + // Build the query dynamically |
| 340 | + const params: SQLInputValue[] = []; |
| 341 | + let sql = |
| 342 | + 'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' + |
| 343 | + // The `snippet` function generates a contextual snippet of the matched text. |
| 344 | + // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. |
| 345 | + "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " + |
| 346 | + 'FROM examples_fts'; |
| 347 | + const whereClauses = []; |
| 348 | + |
| 349 | + // FTS query |
| 350 | + if (query) { |
| 351 | + whereClauses.push('examples_fts MATCH ?'); |
| 352 | + params.push(escapeSearchQuery(query)); |
| 353 | + } |
| 354 | + |
| 355 | + // JSON array filters |
| 356 | + const addJsonFilter = (column: string, values: string[] | undefined) => { |
| 357 | + if (values?.length) { |
| 358 | + for (const value of values) { |
| 359 | + whereClauses.push(`${column} LIKE ?`); |
| 360 | + params.push(`%"${value}"%`); |
274 | 361 | } |
275 | | - text += `\n\n---\n\n${example.content}`; |
276 | | - textContent.push({ type: 'text' as const, text }); |
277 | 362 | } |
| 363 | + }; |
| 364 | + |
| 365 | + addJsonFilter('keywords', keywords); |
| 366 | + addJsonFilter('required_packages', required_packages); |
| 367 | + addJsonFilter('related_concepts', related_concepts); |
278 | 368 |
|
279 | | - return { |
280 | | - content: textContent, |
281 | | - structuredContent: { examples }, |
| 369 | + if (!includeExperimental) { |
| 370 | + whereClauses.push('experimental = 0'); |
| 371 | + } |
| 372 | + |
| 373 | + if (whereClauses.length > 0) { |
| 374 | + sql += ` WHERE ${whereClauses.join(' AND ')}`; |
| 375 | + } |
| 376 | + |
| 377 | + // Order the results by relevance using the BM25 algorithm. |
| 378 | + // The weights assigned to each column boost the ranking of documents where the |
| 379 | + // search term appears in a more important field. |
| 380 | + // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content |
| 381 | + sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);'; |
| 382 | + |
| 383 | + const queryStatement = db.prepare(sql); |
| 384 | + |
| 385 | + // Query database and return results |
| 386 | + const examples = []; |
| 387 | + const textContent = []; |
| 388 | + for (const exampleRecord of queryStatement.all(...params)) { |
| 389 | + const record = exampleRecord as Record<string, string>; |
| 390 | + const example = { |
| 391 | + title: record['title'], |
| 392 | + summary: record['summary'], |
| 393 | + keywords: JSON.parse(record['keywords'] || '[]') as string[], |
| 394 | + required_packages: JSON.parse(record['required_packages'] || '[]') as string[], |
| 395 | + related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[], |
| 396 | + related_tools: JSON.parse(record['related_tools'] || '[]') as string[], |
| 397 | + content: record['content'], |
| 398 | + snippet: record['snippet'], |
282 | 399 | }; |
| 400 | + examples.push(example); |
| 401 | + |
| 402 | + // Also create a more structured text output |
| 403 | + let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`; |
| 404 | + if (example.snippet) { |
| 405 | + text += `\n**Snippet:** ${example.snippet}`; |
| 406 | + } |
| 407 | + text += `\n\n---\n\n${example.content}`; |
| 408 | + textContent.push({ type: 'text' as const, text }); |
| 409 | + } |
| 410 | + |
| 411 | + return { |
| 412 | + content: textContent, |
| 413 | + structuredContent: { examples }, |
283 | 414 | }; |
284 | 415 | } |
285 | 416 |
|
|
0 commit comments