Skip to content
Open
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
1 change: 0 additions & 1 deletion packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ ts_project(
":node_modules/@inquirer/prompts",
":node_modules/@listr2/prompt-adapter-inquirer",
":node_modules/@modelcontextprotocol/sdk",
":node_modules/algoliasearch",
":node_modules/jsonc-parser",
":node_modules/listr2",
":node_modules/npm-package-arg",
Expand Down
1 change: 0 additions & 1 deletion packages/angular/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"@listr2/prompt-adapter-inquirer": "4.2.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@schematics/angular": "workspace:0.0.0-PLACEHOLDER",
"algoliasearch": "5.55.2",
"jsonc-parser": "3.3.1",
"listr2": "10.2.2",
"npm-package-arg": "14.0.0",
Expand Down
111 changes: 60 additions & 51 deletions packages/angular/cli/src/commands/mcp/tools/doc-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch';
import { createDecipheriv } from 'node:crypto';
import { Readable } from 'node:stream';
import { z } from 'zod';
Expand All @@ -32,7 +31,7 @@ const MIN_SUPPORTED_DOCS_VERSION = 17;
* condition where a newly released CLI might default to searching for a documentation index that
* doesn't exist yet.
*/
const LATEST_KNOWN_DOCS_VERSION = 20;
const LATEST_KNOWN_DOCS_VERSION = 22;

const docSearchInputSchema = z.object({
query: z
Expand Down Expand Up @@ -99,41 +98,86 @@ Searches the official Angular documentation (angular.dev) to answer questions ab
});

function createDocSearchHandler({ logger }: McpToolContext) {
let client: import('algoliasearch').SearchClient | undefined;
let apiKey: string | undefined;

return async ({ query, includeTopContent, version }: DocSearchInput) => {
if (!client) {
async function performSearch(query: string, version: number) {
if (!apiKey) {
const dcip = createDecipheriv(
'aes-256-gcm',
(k1 + ALGOLIA_APP_ID).padEnd(32, '^'),
iv,
).setAuthTag(Buffer.from(at, 'base64'));
const { searchClient } = await import('algoliasearch');
client = searchClient(
ALGOLIA_APP_ID,
dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'),
apiKey = dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8');
}

const url = `https://${ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/angular_v${version}/query`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Algolia-Application-Id': ALGOLIA_APP_ID,
'X-Algolia-API-Key': apiKey,
},
body: JSON.stringify({
query,
attributesToRetrieve: [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
hitsPerPage: 10,
}),
signal: AbortSignal.timeout(5000), // Timeout after 5 seconds
});
Comment thread
clydin marked this conversation as resolved.

if (!response.ok) {
throw new Error(
`Search request failed with status ${response.status} (${response.statusText})`,
);
}
Comment thread
clydin marked this conversation as resolved.

const data = (await response.json()) as { hits: Record<string, unknown>[] };

return data.hits;
}

return async ({ query, includeTopContent, version }: DocSearchInput) => {
let finalSearchedVersion = Math.max(
version ?? LATEST_KNOWN_DOCS_VERSION,
MIN_SUPPORTED_DOCS_VERSION,
);
let searchResults;

let allHits: Record<string, unknown>[] | undefined;
try {
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
} catch {}
allHits = await performSearch(query, finalSearchedVersion);
} catch (error) {
logger.warn(`Error searching Angular v${finalSearchedVersion} documentation: ${error}`);
}

// If the initial search for a newer-than-stable version returns no results, it may be because
// the index for that version doesn't exist yet. In this case, fall back to the latest known
// stable version.
if (!searchResults && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
if ((!allHits || allHits.length === 0) && finalSearchedVersion > LATEST_KNOWN_DOCS_VERSION) {
logger.warn(
`Documentation index for v${finalSearchedVersion} not found or empty. Falling back to v${LATEST_KNOWN_DOCS_VERSION}.`,
);
finalSearchedVersion = LATEST_KNOWN_DOCS_VERSION;
searchResults = await client.search(createSearchArguments(query, finalSearchedVersion));
try {
allHits = await performSearch(query, finalSearchedVersion);
} catch (error) {
logger.warn(
`Error searching fallback Angular v${finalSearchedVersion} documentation: ${error}`,
);
}
}
Comment thread
clydin marked this conversation as resolved.

const allHits = searchResults?.results.flatMap((result) => (result as SearchResponse).hits);

if (!allHits?.length) {
return {
content: [
Expand Down Expand Up @@ -282,38 +326,3 @@ function formatHitToParts(hit: Record<string, unknown>): { title: string; breadc

return { title, breadcrumb };
}

/**
* Creates the search arguments for an Algolia search.
*
* The arguments are based on the search implementation in `adev`.
*
* @param query The search query string.
* @returns The search arguments for the Algolia client.
*/
function createSearchArguments(query: string, version: number): LegacySearchMethodProps {
// Search arguments are based on adev's search service:
// https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58
return [
{
indexName: `angular_v${version}`,
params: {
query,
attributesToRetrieve: [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
hitsPerPage: 10,
},
type: 'default',
},
];
}
107 changes: 107 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/doc-search_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @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 { createMockContext } from '../testing/test-utils';
import { DOC_SEARCH_TOOL } from './doc-search';

describe('Doc Search Tool', () => {
let mockContext: ReturnType<typeof createMockContext>['context'];
let fetchSpy: jasmine.Spy;

beforeEach(() => {
const { context } = createMockContext();
mockContext = context;

fetchSpy = spyOn(globalThis, 'fetch');
});

it('should query the correct Algolia endpoint with headers and payload', async () => {
fetchSpy.and.resolveTo(
new Response(
JSON.stringify({
hits: [
{
hierarchy: {
lvl0: 'Docs',
lvl1: 'Standalone Components',
},
url: 'https://angular.dev/guide/standalone-components',
},
],
}),
{ status: 200, statusText: 'OK' },
),
);

const handler = DOC_SEARCH_TOOL.factory(mockContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (handler as any)({
query: 'standalone',
version: 22,
});

expect(fetchSpy).toHaveBeenCalledTimes(1);
const [calledUrl, calledInit] = fetchSpy.calls.first().args;
expect(calledUrl).toBe('https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query');
expect(calledInit.method).toBe('POST');
expect(calledInit.headers['X-Algolia-Application-Id']).toBe('L1XWT2UJ7F');
expect(calledInit.headers['X-Algolia-API-Key']).toBeDefined();

const body = JSON.parse(calledInit.body);
expect(body.query).toBe('standalone');

expect(result.structuredContent.searchedVersion).toBe(22);
expect(result.structuredContent.results.length).toBe(1);
expect(result.structuredContent.results[0].title).toBe('Standalone Components');
});

it('should fallback to latest known version if initial search returns no hits', async () => {
// First call returns empty hits
fetchSpy.and.returnValues(
Promise.resolve(
new Response(JSON.stringify({ hits: [] }), { status: 200, statusText: 'OK' }),
),
// Second fallback call returns a hit
Promise.resolve(
new Response(
JSON.stringify({
hits: [
{
hierarchy: {
lvl0: 'Docs',
lvl1: 'Fallback Guide',
},
url: 'https://angular.dev/guide/fallback',
},
],
}),
{ status: 200, statusText: 'OK' },
),
),
);

const handler = DOC_SEARCH_TOOL.factory(mockContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (handler as any)({
query: 'some-query',
version: 24,
});

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(fetchSpy.calls.first().args[0]).toBe(
'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v24/query',
);
expect(fetchSpy.calls.mostRecent().args[0]).toBe(
'https://L1XWT2UJ7F-dsn.algolia.net/1/indexes/angular_v22/query',
);

expect(result.structuredContent.searchedVersion).toBe(22);
expect(result.structuredContent.results.length).toBe(1);
expect(result.structuredContent.results[0].title).toBe('Fallback Guide');
});
});
Loading
Loading