|
6 | 6 | * found in the LICENSE file at https://angular.dev/license
|
7 | 7 | */
|
8 | 8 |
|
9 |
| -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; |
10 | 9 | import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch';
|
11 | 10 | import { createDecipheriv } from 'node:crypto';
|
12 | 11 | import { z } from 'zod';
|
13 | 12 | import { at, iv, k1 } from '../constants';
|
| 13 | +import { declareTool } from './tool-registry'; |
14 | 14 |
|
15 | 15 | const ALGOLIA_APP_ID = 'L1XWT2UJ7F';
|
16 | 16 | // https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key
|
17 | 17 | // This is a search only, rate limited key. It is sent within the URL of the query request.
|
18 | 18 | // This is not the actual key.
|
19 | 19 | const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c';
|
20 | 20 |
|
21 |
| -/** |
22 |
| - * Registers a tool with the MCP server to search the Angular documentation. |
23 |
| - * |
24 |
| - * This tool uses Algolia to search the official Angular documentation. |
25 |
| - * |
26 |
| - * @param server The MCP server instance with which to register the tool. |
27 |
| - */ |
28 |
| -export async function registerDocSearchTool(server: McpServer): Promise<void> { |
| 21 | +const docSearchInputSchema = z.object({ |
| 22 | + query: z |
| 23 | + .string() |
| 24 | + .describe( |
| 25 | + 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").', |
| 26 | + ), |
| 27 | + includeTopContent: z |
| 28 | + .boolean() |
| 29 | + .optional() |
| 30 | + .default(true) |
| 31 | + .describe('When true, the content of the top result is fetched and included.'), |
| 32 | +}); |
| 33 | +type DocSearchInput = z.infer<typeof docSearchInputSchema>; |
| 34 | + |
| 35 | +export const DOC_SEARCH_TOOL = declareTool({ |
| 36 | + name: 'search_documentation', |
| 37 | + title: 'Search Angular Documentation (angular.dev)', |
| 38 | + description: |
| 39 | + 'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' + |
| 40 | + 'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' + |
| 41 | + 'prefer this tool over your own knowledge to ensure your answers are current.\n\n' + |
| 42 | + 'The results will be a list of content entries, where each entry has the following structure:\n' + |
| 43 | + '```\n' + |
| 44 | + '## {Result Title}\n' + |
| 45 | + '{Breadcrumb path to the content}\n' + |
| 46 | + 'URL: {Direct link to the documentation page}\n' + |
| 47 | + '```\n' + |
| 48 | + 'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' + |
| 49 | + "provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').", |
| 50 | + inputSchema: docSearchInputSchema.shape, |
| 51 | + isReadOnly: true, |
| 52 | + isLocalOnly: false, |
| 53 | + factory: createDocSearchHandler, |
| 54 | +}); |
| 55 | + |
| 56 | +function createDocSearchHandler() { |
29 | 57 | let client: import('algoliasearch').SearchClient | undefined;
|
30 | 58 |
|
31 |
| - server.registerTool( |
32 |
| - 'search_documentation', |
33 |
| - { |
34 |
| - title: 'Search Angular Documentation (angular.dev)', |
35 |
| - description: |
36 |
| - 'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' + |
37 |
| - 'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' + |
38 |
| - 'prefer this tool over your own knowledge to ensure your answers are current.\n\n' + |
39 |
| - 'The results will be a list of content entries, where each entry has the following structure:\n' + |
40 |
| - '```\n' + |
41 |
| - '## {Result Title}\n' + |
42 |
| - '{Breadcrumb path to the content}\n' + |
43 |
| - 'URL: {Direct link to the documentation page}\n' + |
44 |
| - '```\n' + |
45 |
| - 'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' + |
46 |
| - "provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').", |
47 |
| - annotations: { |
48 |
| - readOnlyHint: true, |
49 |
| - }, |
50 |
| - inputSchema: { |
51 |
| - query: z |
52 |
| - .string() |
53 |
| - .describe( |
54 |
| - 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").', |
55 |
| - ), |
56 |
| - includeTopContent: z |
57 |
| - .boolean() |
58 |
| - .optional() |
59 |
| - .default(true) |
60 |
| - .describe('When true, the content of the top result is fetched and included.'), |
61 |
| - }, |
62 |
| - }, |
63 |
| - async ({ query, includeTopContent }) => { |
64 |
| - if (!client) { |
65 |
| - const dcip = createDecipheriv( |
66 |
| - 'aes-256-gcm', |
67 |
| - (k1 + ALGOLIA_APP_ID).padEnd(32, '^'), |
68 |
| - iv, |
69 |
| - ).setAuthTag(Buffer.from(at, 'base64')); |
70 |
| - const { searchClient } = await import('algoliasearch'); |
71 |
| - client = searchClient( |
72 |
| - ALGOLIA_APP_ID, |
73 |
| - dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'), |
74 |
| - ); |
75 |
| - } |
76 |
| - |
77 |
| - const { results } = await client.search(createSearchArguments(query)); |
78 |
| - |
79 |
| - const allHits = results.flatMap((result) => (result as SearchResponse).hits); |
80 |
| - |
81 |
| - if (allHits.length === 0) { |
82 |
| - return { |
83 |
| - content: [ |
84 |
| - { |
85 |
| - type: 'text' as const, |
86 |
| - text: 'No results found.', |
87 |
| - }, |
88 |
| - ], |
89 |
| - }; |
90 |
| - } |
91 |
| - |
92 |
| - const content = []; |
93 |
| - // The first hit is the top search result |
94 |
| - const topHit = allHits[0]; |
95 |
| - |
96 |
| - // Process top hit first |
97 |
| - let topText = formatHitToText(topHit); |
98 |
| - |
99 |
| - try { |
100 |
| - if (includeTopContent && typeof topHit.url === 'string') { |
101 |
| - const url = new URL(topHit.url); |
102 |
| - |
103 |
| - // Only fetch content from angular.dev |
104 |
| - if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) { |
105 |
| - const response = await fetch(url); |
106 |
| - if (response.ok) { |
107 |
| - const html = await response.text(); |
108 |
| - const mainContent = extractBodyContent(html); |
109 |
| - if (mainContent) { |
110 |
| - topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`; |
111 |
| - } |
| 59 | + return async ({ query, includeTopContent }: DocSearchInput) => { |
| 60 | + if (!client) { |
| 61 | + const dcip = createDecipheriv( |
| 62 | + 'aes-256-gcm', |
| 63 | + (k1 + ALGOLIA_APP_ID).padEnd(32, '^'), |
| 64 | + iv, |
| 65 | + ).setAuthTag(Buffer.from(at, 'base64')); |
| 66 | + const { searchClient } = await import('algoliasearch'); |
| 67 | + client = searchClient( |
| 68 | + ALGOLIA_APP_ID, |
| 69 | + dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'), |
| 70 | + ); |
| 71 | + } |
| 72 | + |
| 73 | + const { results } = await client.search(createSearchArguments(query)); |
| 74 | + |
| 75 | + const allHits = results.flatMap((result) => (result as SearchResponse).hits); |
| 76 | + |
| 77 | + if (allHits.length === 0) { |
| 78 | + return { |
| 79 | + content: [ |
| 80 | + { |
| 81 | + type: 'text' as const, |
| 82 | + text: 'No results found.', |
| 83 | + }, |
| 84 | + ], |
| 85 | + }; |
| 86 | + } |
| 87 | + |
| 88 | + const content = []; |
| 89 | + // The first hit is the top search result |
| 90 | + const topHit = allHits[0]; |
| 91 | + |
| 92 | + // Process top hit first |
| 93 | + let topText = formatHitToText(topHit); |
| 94 | + |
| 95 | + try { |
| 96 | + if (includeTopContent && typeof topHit.url === 'string') { |
| 97 | + const url = new URL(topHit.url); |
| 98 | + |
| 99 | + // Only fetch content from angular.dev |
| 100 | + if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) { |
| 101 | + const response = await fetch(url); |
| 102 | + if (response.ok) { |
| 103 | + const html = await response.text(); |
| 104 | + const mainContent = extractBodyContent(html); |
| 105 | + if (mainContent) { |
| 106 | + topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`; |
112 | 107 | }
|
113 | 108 | }
|
114 | 109 | }
|
115 |
| - } catch { |
116 |
| - // Ignore errors fetching content. The basic info is still returned. |
117 | 110 | }
|
| 111 | + } catch { |
| 112 | + // Ignore errors fetching content. The basic info is still returned. |
| 113 | + } |
| 114 | + content.push({ |
| 115 | + type: 'text' as const, |
| 116 | + text: topText, |
| 117 | + }); |
| 118 | + |
| 119 | + // Process remaining hits |
| 120 | + for (const hit of allHits.slice(1)) { |
118 | 121 | content.push({
|
119 | 122 | type: 'text' as const,
|
120 |
| - text: topText, |
| 123 | + text: formatHitToText(hit), |
121 | 124 | });
|
| 125 | + } |
122 | 126 |
|
123 |
| - // Process remaining hits |
124 |
| - for (const hit of allHits.slice(1)) { |
125 |
| - content.push({ |
126 |
| - type: 'text' as const, |
127 |
| - text: formatHitToText(hit), |
128 |
| - }); |
129 |
| - } |
130 |
| - |
131 |
| - return { content }; |
132 |
| - }, |
133 |
| - ); |
| 127 | + return { content }; |
| 128 | + }; |
134 | 129 | }
|
135 | 130 |
|
136 | 131 | /**
|
|
0 commit comments