Skip to content

Commit a18c1aa

Browse files
committed
refactor(@angular/cli): include content for top result in MCP documentation search tool
When using the documentation search MCP tool within the Angular CLI's MCP server, the top search result will now also include the main content of the documentation page. This removes the need for followup action to retrieve the content for the likely needed information. Any additional results found will continue to include the URL but no content.
1 parent 58065c8 commit a18c1aa

File tree

1 file changed

+94
-29
lines changed

1 file changed

+94
-29
lines changed

packages/angular/cli/src/commands/mcp/tools/doc-search.ts

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
5353
.describe(
5454
'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").',
5555
),
56+
includeTopContent: z
57+
.boolean()
58+
.optional()
59+
.default(true)
60+
.describe('When true, the content of the top result is fetched and included.'),
5661
},
5762
},
58-
async ({ query }) => {
63+
async ({ query, includeTopContent }) => {
5964
if (!client) {
6065
const dcip = createDecipheriv(
6166
'aes-256-gcm',
@@ -71,40 +76,100 @@ export async function registerDocSearchTool(server: McpServer): Promise<void> {
7176

7277
const { results } = await client.search(createSearchArguments(query));
7378

74-
// Convert results into text content entries instead of stringifying the entire object
75-
const content = results.flatMap((result) =>
76-
(result as SearchResponse).hits.map((hit) => {
77-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78-
const hierarchy = Object.values(hit.hierarchy as any).filter(
79-
(x) => typeof x === 'string',
80-
);
81-
const title = hierarchy.pop();
82-
const description = hierarchy.join(' > ');
83-
84-
return {
85-
type: 'text' as const,
86-
text: `## ${title}\n${description}\nURL: ${hit.url}`,
87-
};
88-
}),
89-
);
90-
91-
// Return the search results if any are found
92-
if (content.length > 0) {
93-
return { content };
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+
};
9490
}
9591

96-
return {
97-
content: [
98-
{
99-
type: 'text' as const,
100-
text: 'No results found.',
101-
},
102-
],
103-
};
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+
}
112+
}
113+
}
114+
}
115+
} catch {
116+
// Ignore errors fetching content. The basic info is still returned.
117+
}
118+
content.push({
119+
type: 'text' as const,
120+
text: topText,
121+
});
122+
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 };
104132
},
105133
);
106134
}
107135

136+
/**
137+
* Extracts the content of the `<body>` element from an HTML string.
138+
*
139+
* @param html The HTML content of a page.
140+
* @returns The content of the `<body>` element, or `undefined` if not found.
141+
*/
142+
function extractBodyContent(html: string): string | undefined {
143+
// TODO: Use '<main>' element instead of '<body>' when available in angular.dev HTML.
144+
const mainTagStart = html.indexOf('<body');
145+
if (mainTagStart === -1) {
146+
return undefined;
147+
}
148+
149+
const mainTagEnd = html.lastIndexOf('</body>');
150+
if (mainTagEnd <= mainTagStart) {
151+
return undefined;
152+
}
153+
154+
// Add 7 to include '</body>'
155+
return html.substring(mainTagStart, mainTagEnd + 7);
156+
}
157+
158+
/**
159+
* Formats an Algolia search hit into a text representation.
160+
*
161+
* @param hit The Algolia search hit object, which should contain `hierarchy` and `url` properties.
162+
* @returns A formatted string with title, description, and URL.
163+
*/
164+
function formatHitToText(hit: Record<string, unknown>): string {
165+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
166+
const hierarchy = Object.values(hit.hierarchy as any).filter((x) => typeof x === 'string');
167+
const title = hierarchy.pop();
168+
const description = hierarchy.join(' > ');
169+
170+
return `## ${title}\n${description}\nURL: ${hit.url}`;
171+
}
172+
108173
/**
109174
* Creates the search arguments for an Algolia search.
110175
*

0 commit comments

Comments
 (0)