diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/lib/api-doc-loader.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/lib/api-doc-loader.test.ts index f5d877f8f..c462eec6d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/lib/api-doc-loader.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/lib/api-doc-loader.test.ts @@ -158,6 +158,62 @@ describe('ApiDocLoader', () => { const loader = createLoadedLoader(); expect(loader.get('react', 'IgxGridComponent')).toBeUndefined(); }); + + it('falls back to a case-insensitive match', () => { + const loader = createLoadedLoader(); + expect(loader.get('angular', 'igxgridcomponent')?.component).toBe('IgxGridComponent'); + }); + + describe('generic-typed names', () => { + const GENERIC_CONTENT = [ + '### [IgbCombo](https://example.com/blazor/igbcombo)', + '', + 'A combo box component.', + '', + '### [DynamicContentInfo](https://example.com/blazor/dci-generic)', + '', + 'Generic variant.', + '', + '### [DynamicContentInfo](https://example.com/blazor/dci)', + '', + 'Non-generic variant.', + '', + ].join('\n'); + + function createBlazorLoader(): ApiDocLoader { + setupFsMocks(); + mockReadFileSync.mockReturnValue(GENERIC_CONTENT); + const loader = new ApiDocLoader([{ ...FIXTURE_CONFIG, key: 'blazor', displayName: 'Blazor' }]); + loader.load(); + return loader; + } + + it('resolves the plain class name to the generic-typed entry', () => { + const loader = createBlazorLoader(); + expect(loader.get('blazor', 'IgbCombo')?.component).toBe('IgbCombo'); + }); + + it('still resolves the exact generic-typed name', () => { + const loader = createBlazorLoader(); + expect(loader.get('blazor', 'IgbCombo')?.component).toBe('IgbCombo'); + }); + + it('resolves a differently-parameterised spelling to the same entry', () => { + const loader = createBlazorLoader(); + expect(loader.get('blazor', 'IgbCombo')?.component).toBe('IgbCombo'); + }); + + it('combines case-insensitive and generic-stripped matching', () => { + const loader = createBlazorLoader(); + expect(loader.get('blazor', 'igbcombo')?.component).toBe('IgbCombo'); + }); + + it('prefers the exact non-generic entry when both spellings are indexed', () => { + const loader = createBlazorLoader(); + expect(loader.get('blazor', 'DynamicContentInfo')?.component).toBe('DynamicContentInfo'); + expect(loader.get('blazor', 'DynamicContentInfo')?.component).toBe('DynamicContentInfo'); + }); + }); }); describe('search()', () => { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts index a9f75fe75..2df96d757 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { applyDocAlias, normalizeDocName, sanitizeSearchDocsQuery } from '../../tools/doc-tools.js'; +import { + applyCompactGridPrefix, + applyDocAlias, + formatSubstitutionNotice, + normalizeDocName, + parseDocNames, + resolveDoc, + sanitizeSearchDocsQuery, +} from '../../tools/doc-tools.js'; +import type { DocsProvider } from '../../providers/DocsProvider.js'; describe('sanitizeSearchDocsQuery', () => { it('quotes plain terms with AND (implicit in FTS4)', () => { @@ -81,6 +90,32 @@ describe('sanitizeSearchDocsQuery', () => { it('handles realistic user query with special chars injected', () => { expect(sanitizeSearchDocsQuery('grid" OR "1=1')).toBe('"grid" "OR" "1=1"'); }); + + it('strips natural-language stopwords (how do I ...)', () => { + expect(sanitizeSearchDocsQuery('how do I enable row editing')).toBe( + '"enable" "row" "editing"', + ); + }); + + it('strips a leading article', () => { + expect(sanitizeSearchDocsQuery('the grid selection')).toBe('"grid" "selection"'); + }); + + it('keeps and/or/but as ordinary terms (not stopwords)', () => { + expect(sanitizeSearchDocsQuery('drag and drop')).toBe('"drag" "and" "drop"'); + }); + + it('falls back to full terms when every term is a stopword', () => { + expect(sanitizeSearchDocsQuery('how do I')).toBe('"how" "do" "I"'); + }); + + it('falls back to full terms when the non-stopwords are all invalid', () => { + expect(sanitizeSearchDocsQuery('* the')).toBe('"the"'); + }); + + it('does not strip meaningful component words that resemble nothing in the list', () => { + expect(sanitizeSearchDocsQuery('column pinning')).toBe('"column" "pinning"'); + }); }); describe('normalizeDocName', () => { @@ -127,6 +162,26 @@ describe('normalizeDocName', () => { it('falls back to lowercased input when normalization yields empty string', () => { expect(normalizeDocName('Igx')).toBe('igx'); }); + + it('kebab-cases a spaced multi-word name', () => { + expect(normalizeDocName('date picker')).toBe('date-picker'); + }); + + it('kebab-cases a three-word name', () => { + expect(normalizeDocName('navigation drawer panel')).toBe('navigation-drawer-panel'); + }); + + it('collapses multiple spaces and trims', () => { + expect(normalizeDocName(' tree grid ')).toBe('tree-grid'); + }); + + it('converts underscores to hyphens', () => { + expect(normalizeDocName('color_editor')).toBe('color-editor'); + }); + + it('leaves a class name equivalent to its spaced form', () => { + expect(normalizeDocName('IgxDatePicker')).toBe(normalizeDocName('date picker')); + }); }); describe('applyDocAlias', () => { @@ -195,3 +250,246 @@ describe('applyDocAlias', () => { expect(applyDocAlias('react', normalized)).toBe('overview'); }); }); + +describe('resolveDoc', () => { + // Stub provider backed by a fixed set of known doc filenames. searchDocs + // returns the LocalDocsProvider-style markdown (all known docs, in insertion + // order = rank order) so the fallback parser, guard, and iteration are exercised. + function makeProvider(known: Record): DocsProvider { + return { + async listComponents() { + return ''; + }, + async getDoc(_framework: string, name: string) { + const key = name.replace(/\.md$/, ''); + return key in known + ? { text: known[key], found: true } + : { text: 'not found', found: false }; + }, + async searchDocs(_framework: string, _query: string) { + const keys = Object.keys(known); + if (keys.length === 0) return 'No results'; + return keys.map((k) => `- **X** (\`${k}\`)`).join('\n'); + }, + }; + } + + it('resolves a direct name', async () => { + const p = makeProvider({ accordion: 'ACC' }); + const r = await resolveDoc(p, 'angular', 'accordion'); + expect(r).toMatchObject({ found: true, servedName: 'accordion', text: 'ACC' }); + }); + + it('applies the grid- prefix fallback for bare feature names', async () => { + const p = makeProvider({ 'grid-sorting': 'SORT' }); + const r = await resolveDoc(p, 'angular', 'sorting'); + expect(r).toMatchObject({ found: true, servedName: 'grid-sorting' }); + }); + + it('falls back to search when the name does not resolve mechanically', async () => { + const p = makeProvider({ navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r.found).toBe(true); + expect(r.servedName).toBe('navdrawer'); + expect(r.text).toBe('NAV'); + }); + + + it('resolves through the search fallback when the provider returns remote-format results', async () => { + // RemoteDocsProvider proxies DocsController.cs, which renders each hit as + // `**doc-name** [Component]` followed by an excerpt line — no backticks. + const p: DocsProvider = { + async listComponents() { + return ''; + }, + async getDoc(_framework: string, name: string) { + return name === 'navdrawer' + ? { text: 'NAV', found: true } + : { text: 'not found', found: false }; + }, + async searchDocs() { + return [ + '**navdrawer** [IgxNavigationDrawer]', + 'The **Navigation Drawer** slides in from the side...', + '', + '**grid-sorting** [IgxGrid]', + 'Sort rows by column.', + ].join('\n'); + }, + }; + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r).toMatchObject({ found: true, servedName: 'navdrawer', text: 'NAV', fuzzy: true }); + }); + it('returns not found when search also yields nothing', async () => { + const p = makeProvider({}); // searchDocs returns "No results" + const r = await resolveDoc(p, 'angular', 'totally unknown widget'); + expect(r.found).toBe(false); + }); + + it('returns the original not-found result when the search fallback throws', async () => { + const p: DocsProvider = { + ...makeProvider({}), + async searchDocs() { + throw new Error('Backend returned 500: boom'); + }, + }; + const r = await resolveDoc(p, 'angular', 'totally unknown widget'); + expect(r).toMatchObject({ found: false, fuzzy: false, text: 'not found' }); + }); + + it('rejects an unrelated search hit that shares no token with the request', async () => { + const p = makeProvider({ 'grid-paste-excel': 'X' }); + const r = await resolveDoc(p, 'angular', 'textarea'); + expect(r.found).toBe(false); // guard rejects; better an honest miss than a wrong doc + }); + + it('matches search hits case-insensitively against the request', async () => { + const p = makeProvider({ 'zoomSlider-overview': 'ZS' }); + const r = await resolveDoc(p, 'angular', 'zoomslider'); + expect(r).toMatchObject({ found: true, servedName: 'zoomSlider-overview', fuzzy: true }); + }); + + it('skips an unrelated top hit and accepts a lower-ranked one that shares a token', async () => { + // Top hit unrelated; second hit shares the "drawer" token. + const p = makeProvider({ 'grid-paste-excel': 'X', navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r).toMatchObject({ found: true, servedName: 'navdrawer', text: 'NAV' }); + }); + + it('accepts a feature doc that shares the component token', async () => { + const p = makeProvider({ 'treegrid-export-excel': 'T' }); + const r = await resolveDoc(p, 'angular', 'treegrid'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-export-excel' }); + }); + + it('rewrites an angular tree-grid- topic name to the compact doc key', async () => { + const p = makeProvider({ 'treegrid-filtering': 'TF' }); + const r = await resolveDoc(p, 'angular', 'tree-grid-filtering'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-filtering', fuzzy: false }); + }); + + it('rewrites angular hierarchical-grid- and pivot-grid- topic names', async () => { + const p = makeProvider({ 'hierarchicalgrid-paging': 'HP', 'pivotGrid-state-persistence': 'PS' }); + await expect(resolveDoc(p, 'angular', 'hierarchical-grid-paging')).resolves.toMatchObject({ + found: true, + servedName: 'hierarchicalgrid-paging', + }); + await expect(resolveDoc(p, 'angular', 'pivot-grid-state-persistence')).resolves.toMatchObject({ + found: true, + servedName: 'pivotGrid-state-persistence', + fuzzy: false, + }); + }); + + it('prefers the exact compact doc over a related search hit', async () => { + // Regression: "tree-grid-editing" used to fall through to search and serve + // treegrid-batch-editing, which covers a different feature. + const p = makeProvider({ 'treegrid-batch-editing': 'BATCH', 'treegrid-editing': 'EDIT' }); + const r = await resolveDoc(p, 'angular', 'tree-grid-editing'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-editing', text: 'EDIT', fuzzy: false }); + }); + + it('does not rewrite grid prefixes for non-angular frameworks', async () => { + // React keys these docs with the hyphenated form; a rewrite would break them. + const p = makeProvider({ 'tree-grid-filtering': 'TF' }); + const r = await resolveDoc(p, 'react', 'tree-grid-filtering'); + expect(r).toMatchObject({ found: true, servedName: 'tree-grid-filtering' }); + }); + + it('marks a search-fallback resolution as fuzzy', async () => { + const p = makeProvider({ navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r.fuzzy).toBe(true); + }); + + it('does not mark deterministic resolutions as fuzzy', async () => { + const direct = await resolveDoc(makeProvider({ accordion: 'ACC' }), 'angular', 'accordion'); + expect(direct.fuzzy).toBe(false); + + const aliased = await resolveDoc(makeProvider({ 'grid-grid': 'G' }), 'angular', 'IgxGrid'); + expect(aliased).toMatchObject({ found: true, fuzzy: false }); + + const gridPrefixed = await resolveDoc(makeProvider({ 'grid-sorting': 'S' }), 'angular', 'sorting'); + expect(gridPrefixed).toMatchObject({ found: true, fuzzy: false }); + }); + + it('is not fuzzy when nothing was found at all', async () => { + const r = await resolveDoc(makeProvider({}), 'angular', 'totally unknown widget'); + expect(r).toMatchObject({ found: false, fuzzy: false }); + }); +}); + +describe('applyCompactGridPrefix', () => { + it('rewrites the three angular grid-variant prefixes', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid-filtering')).toBe('treegrid-filtering'); + expect(applyCompactGridPrefix('angular', 'hierarchical-grid-paging')).toBe('hierarchicalgrid-paging'); + expect(applyCompactGridPrefix('angular', 'pivot-grid-sorting')).toBe('pivotGrid-sorting'); + }); + + it('returns null when no prefix matches', () => { + expect(applyCompactGridPrefix('angular', 'grid-sorting')).toBeNull(); + expect(applyCompactGridPrefix('angular', 'accordion')).toBeNull(); + }); + + it('returns null for the bare component name (no topic suffix)', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid')).toBeNull(); + }); + + it('returns null for non-angular frameworks', () => { + expect(applyCompactGridPrefix('react', 'tree-grid-filtering')).toBeNull(); + expect(applyCompactGridPrefix('blazor', 'hierarchical-grid-paging')).toBeNull(); + expect(applyCompactGridPrefix('webcomponents', 'pivot-grid-sorting')).toBeNull(); + }); + + it('preserves multi-segment topics', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid-column-moving')).toBe('treegrid-column-moving'); + }); +}); + +describe('formatSubstitutionNotice', () => { + it('names both the requested and the served doc', () => { + const notice = formatSubstitutionNotice('tree-grid-editing', 'treegrid-batch-editing'); + expect(notice).toContain('`tree-grid-editing`'); + expect(notice).toContain('`treegrid-batch-editing`'); + }); + + it('points at the discovery tools', () => { + const notice = formatSubstitutionNotice('x', 'y'); + expect(notice).toContain('list_components'); + expect(notice).toContain('search_docs'); + }); +}); + +describe('parseDocNames', () => { + it('parses local-format results from the backtick token, not the bold toc title', () => { + const out = [ + 'Found 2 results for "sort" in **angular**:', + '', + '- **Grid Sorting** (`grid-sorting`)', + ' Sort rows by one or more columns.', + '- **Tree Grid Sorting** (`treegrid-sorting`)', + ].join('\n'); + expect(parseDocNames(out)).toEqual(['grid-sorting', 'treegrid-sorting']); + }); + + it('parses remote-format results from the line-leading bold doc name', () => { + const out = [ + '**grid-sorting** [IgxGrid]', + 'Sort rows by one or more columns.', + '', + '**treegrid-sorting**', + 'Tree grid sorting excerpt.', + ].join('\n'); + expect(parseDocNames(out)).toEqual(['grid-sorting', 'treegrid-sorting']); + }); + + it('ignores bold prose inside a remote excerpt', () => { + const out = ['**grid-editing** [IgxGrid]', '**Note:** editing requires primaryKey.'].join('\n'); + expect(parseDocNames(out)).toEqual(['grid-editing']); + }); + + it('returns an empty list for a no-results message', () => { + expect(parseDocNames('No results found for "xyz".')).toEqual([]); + expect(parseDocNames('No results')).toEqual([]); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/handlers.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/handlers.test.ts index 8ab0ae0f7..bf9a58f70 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/handlers.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/handlers.test.ts @@ -50,6 +50,26 @@ describe('createGetApiReferenceHandler', () => { expect(result.content[0].text).toContain('Events'); }); + it('returns the full entry when member is an empty string (blank member sent for "full entry")', async () => { + const loader = makeLoader({ get: vi.fn().mockReturnValue(makeEntry()) }); + const handler = createGetApiReferenceHandler(loader); + const result = await handler({ platform: 'angular', component: 'IgxGridComponent', section: 'all', member: '' }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Properties'); + expect(result.content[0].text).toContain('Methods'); + expect(result.content[0].text).toContain('Events'); + }); + + it('returns the requested section when member is an empty string', async () => { + const loader = makeLoader({ get: vi.fn().mockReturnValue(makeEntry()) }); + const handler = createGetApiReferenceHandler(loader); + const result = await handler({ platform: 'angular', component: 'IgxGridComponent', section: 'properties', member: '' }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('- properties'); + }); + it('returns isError when component is not found', async () => { const loader = makeLoader(); const handler = createGetApiReferenceHandler(loader); @@ -60,16 +80,37 @@ describe('createGetApiReferenceHandler', () => { expect(result.content[0].text).toContain('not found'); }); - it('falls back to case-insensitive match', async () => { - const entry = makeEntry({ component: 'IgxGridComponent' }); - const loader = makeLoader({ - get: vi.fn().mockReturnValueOnce(undefined).mockReturnValue(entry), - search: vi.fn().mockReturnValue([entry]), - }); + it('delegates fuzzy name matching to the loader and reports the resolved name', async () => { + // ApiDocLoader.get handles case-insensitive and generic-stripped lookups; + // the handler just uses whatever entry comes back. + const entry = makeEntry({ component: 'IgbCombo', platform: 'blazor' }); + const loader = makeLoader({ get: vi.fn().mockReturnValue(entry) }); const handler = createGetApiReferenceHandler(loader); - const result = await handler({ platform: 'angular', component: 'igxgridcomponent', section: 'all' }); + const result = await handler({ platform: 'blazor', component: 'IgbCombo', section: 'properties' }); + expect(loader.get).toHaveBeenCalledWith('blazor', 'IgbCombo'); expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('# IgbCombo (blazor) - properties'); + }); + + it('treats a placeholder member with no letters or digits as omitted', async () => { + const loader = makeLoader({ get: vi.fn().mockReturnValue(makeEntry()) }); + const handler = createGetApiReferenceHandler(loader); + + for (const member of ['.*', '*', '-', '?']) { + const result = await handler({ platform: 'angular', component: 'IgxGridComponent', section: 'all', member }); + expect(result.isError, `member=${JSON.stringify(member)}`).toBeUndefined(); + expect(result.content[0].text).toContain('Methods'); + } + }); + + it('still reports not-found for a real-looking member that does not exist', async () => { + const loader = makeLoader({ get: vi.fn().mockReturnValue(makeEntry()) }); + const handler = createGetApiReferenceHandler(loader); + const result = await handler({ platform: 'angular', component: 'IgxGridComponent', section: 'all', member: ':invalid' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Member ":invalid" not found'); }); it('returns isError with suggestion to use search_api when not found even case-insensitively', async () => { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/schemas.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/schemas.test.ts index 3292dd8a1..399efc6f6 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/schemas.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/schemas.test.ts @@ -74,12 +74,21 @@ describe('getApiReferenceSchema', () => { expect(result.member).toBe('checked'); }); - it('rejects empty member name', () => { + it('accepts an empty member name (treated as omitted by the handler)', () => { expect(getApiReferenceSchema.safeParse({ platform: 'angular', component: 'IgxGrid', member: '', - }).success).toBe(false); + }).success).toBe(true); + }); + + it('accepts a whitespace-only member name and trims it to empty', () => { + const result = getApiReferenceSchema.parse({ + platform: 'angular', + component: 'IgxGrid', + member: ' ', + }); + expect(result.member).toBe(''); }); it('rejects member name exceeding 128 characters', () => { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts index 5c8c4eac2..78682a40d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts @@ -12,7 +12,7 @@ import { RemoteDocsProvider } from "./providers/RemoteDocsProvider.js"; import { LocalDocsProvider } from "./providers/LocalDocsProvider.js"; import { getApiReferenceSchema, searchApiSchema } from "./tools/schemas.js"; import { createGetApiReferenceHandler, createSearchApiHandler } from "./tools/handlers.js"; -import { applyDocAlias, buildProjectSetupGuide, normalizeDocName, sanitizeSearchDocsQuery } from "./tools/doc-tools.js"; +import { buildProjectSetupGuide, formatSubstitutionNotice, resolveDoc, sanitizeSearchDocsQuery } from "./tools/doc-tools.js"; import { ApiDocLoader } from "./lib/api-doc-loader.js"; import { getPlatforms } from "./config/platforms.js"; @@ -160,24 +160,12 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) { }, async ({ framework, name }) => { const start = performance.now(); - const resolvedName = applyDocAlias(framework, normalizeDocName(name.trim())); - let { text, found } = await docsProvider.getDoc(framework, resolvedName); + const { text, found, servedName, fuzzy } = await resolveDoc(docsProvider, framework, name); - // Generic grid-prefix fallback: if the doc isn't found and the name doesn't - // already start with a component-type prefix, try "grid-{name}". - // This handles bare feature names like "sorting", "remote-data-operations", - // "row-editing" etc. without needing an explicit alias for every grid sub-doc. - let servedName = resolvedName; - if (!found && !/^(grid|hierarchical|tree|pivot|hierarchicalgrid|treegrid|pivotgrid|combo|drop-down|select|for-of)[-]/.test(resolvedName)) { - const withGridPrefix = await docsProvider.getDoc(framework, `grid-${resolvedName}`); - if (withGridPrefix.found) { - ({ text, found } = withGridPrefix); - servedName = `grid-${resolvedName}`; - } - } + const body = fuzzy ? `${formatSubstitutionNotice(name, servedName)}\n\n${text}` : text; - log("get_doc", { framework, name: servedName }, text, Math.round(performance.now() - start)); - return { content: [{ type: "text" as const, text }], ...(found ? {} : { isError: true }) }; + log("get_doc", { framework, name: servedName }, body, Math.round(performance.now() - start)); + return { content: [{ type: "text" as const, text: body }], ...(found ? {} : { isError: true }) }; } ); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/lib/api-doc-loader.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/lib/api-doc-loader.ts index f7d594ad9..c7a30071f 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/lib/api-doc-loader.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/lib/api-doc-loader.ts @@ -13,8 +13,16 @@ export class ApiDocsInitializationError extends Error { } } +/** Drop a generic type parameter list: "IgbCombo" → "IgbCombo". */ +export function stripGenerics(name: string): string { + return name.replace(/<[^>]*>/g, '').trim(); +} + export class ApiDocLoader { private docs = new Map(); + // Secondary index keyed by lower-cased, generic-stripped name ("blazor:igbcombo" + // for IgbCombo), so callers can use the plain class name the docs refer to. + private docsByBaseName = new Map(); private platformConfigs: PlatformConfig[]; constructor(platformConfigs: PlatformConfig[]) { @@ -116,7 +124,7 @@ export class ApiDocLoader { .filter(Boolean); const key = `${config.key}:${componentName}`; - this.docs.set(key, { + const entry: DocEntry = { filepath: llmsFile, content: chunk, title: componentName, @@ -125,7 +133,9 @@ export class ApiDocLoader { keywords, summary: summaryLine.trim(), platform: config.key, - }); + }; + this.docs.set(key, entry); + this.indexBaseName(config.key, componentName, entry); count++; } } @@ -162,8 +172,31 @@ export class ApiDocLoader { return 'class'; } + /** + * Register an entry under its base name. Later entries replace earlier ones, + * mirroring the exact-name map, except that a non-generic name is never + * shadowed by a generic one (Blazor has both DynamicContentInfo and + * DynamicContentInfo). + */ + private indexBaseName(platform: string, componentName: string, entry: DocEntry): void { + const base = stripGenerics(componentName); + const key = `${platform}:${base.toLowerCase()}`; + const existing = this.docsByBaseName.get(key); + const existingIsGeneric = existing !== undefined && stripGenerics(existing.component) !== existing.component; + if (!existing || existingIsGeneric || base === componentName) { + this.docsByBaseName.set(key, entry); + } + } + + /** + * Exact lookup first; then a case-insensitive, generic-stripped lookup so + * "IgbCombo" (or "igbcombo") resolves to the indexed "IgbCombo". + */ get(platform: Platform, name: string): DocEntry | undefined { - return this.docs.get(`${platform}:${name}`); + return ( + this.docs.get(`${platform}:${name}`) ?? + this.docsByBaseName.get(`${platform}:${stripGenerics(name).toLowerCase()}`) + ); } search(options: { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts index fedd48f66..c5ade2763 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts @@ -17,27 +17,42 @@ export const MISSING_FRAMEWORK_MESSAGE = // terms must appear in the document. This is far more precise than OR: // "virtual scroll" → `"virtual" "scroll"` (both required) // Single-word and prefix queries are unaffected by this change. +// Natural-language filler words dropped before FTS4 matching. FTS4 uses implicit +// AND, so leaving "how"/"do"/"i" in a query like "how do I enable row editing" +// forces those words to appear in a doc and collapses recall to near zero. +// Deliberately excludes and/or/but — those are left as ordinary terms. +const SEARCH_STOPWORDS = new Set([ + 'how', 'do', 'does', 'did', 'i', 'a', 'an', 'the', 'to', 'of', 'in', 'on', + 'is', 'are', 'am', 'be', 'my', 'me', 'we', 'you', 'your', 'it', 'its', + 'this', 'that', 'these', 'those', 'when', 'what', 'which', 'who', 'why', + 'want', 'need', 'can', 'could', 'would', 'should', 'please', 'help', +]); + +// Quote a plain term for FTS4, or pass through a prefix query (grid*). Bare +// asterisks have no prefix and would be an FTS4 syntax error — drop them. +function quoteOrPrefixTerm(term: string): string | null { + if (term.endsWith('*')) { + return /[^*]/.test(term) ? term : null; + } + return `"${term}"`; +} + export function sanitizeSearchDocsQuery(queryText: string): string | null { - const sanitized = queryText + const rawTerms = queryText .replace(/["(){}[\]:@]/g, ' ') .split(/\s+/) - .filter(Boolean) - .map((term) => { - // Terms ending with * are prefix queries — don't quote them - // because FTS4 treats "grid*" as a literal match for the - // asterisk character, while unquoted grid* does prefix expansion. - // Drop terms that are only asterisks (e.g. *, **) — they have - // no actual prefix and would cause an FTS4 syntax error. - if (term.endsWith('*')) { - return /[^*]/.test(term) ? term : null; - } + .filter(Boolean); - return `"${term}"`; - }) - .filter((term): term is string => Boolean(term)) - .join(' '); + const toQuery = (terms: string[]) => + terms + .map(quoteOrPrefixTerm) + .filter((term): term is string => Boolean(term)) + .join(' '); - return sanitized || null; + // Strip stopwords, but if that leaves nothing usable (e.g. a pure "how do I" + // query) fall back to the full term list rather than returning no query at all. + const meaningful = toQuery(rawTerms.filter((t) => !SEARCH_STOPWORDS.has(t.toLowerCase()))); + return meaningful || toQuery(rawTerms) || null; } /** @@ -52,9 +67,13 @@ export function sanitizeSearchDocsQuery(queryText: string): string | null { * 3. Convert PascalCase / camelCase to kebab-case and lowercase */ export function normalizeDocName(name: string): string { - let normalized = name.replace(/^Ig[xrcb]/i, ''); + let normalized = name.trim().replace(/^Ig[xrcb]/i, ''); normalized = normalized.replace(/Component$/i, ''); - normalized = normalized.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + normalized = normalized.replace(/([a-z0-9])([A-Z])/g, '$1-$2'); + // Collapse spaces/underscores to hyphens so multi-word names ("date picker", + // "tree grid") resolve like their kebab-case doc keys. + normalized = normalized.replace(/[\s_]+/g, '-').toLowerCase(); + normalized = normalized.replace(/-+/g, '-').replace(/^-|-$/g, ''); return normalized || name.toLowerCase(); } @@ -116,7 +135,7 @@ const DOC_ALIASES: Record> = { grid: 'grid-grid', 'hierarchical-grid': 'hierarchicalgrid-hierarchical-grid', 'tree-grid': 'treegrid-tree-grid', - 'pivot-grid': 'pivotgrid-pivot-grid', + 'pivot-grid': 'pivotGrid-pivot-grid', spreadsheet: 'spreadsheet-overview', 'zoom-slider': 'zoomslider-overview', zoomslider: 'zoomslider-overview', @@ -199,6 +218,181 @@ export function applyDocAlias(framework: string, normalizedName: string): string return DOC_ALIASES[framework]?.[normalizedName] ?? normalizedName; } +/** + * Angular keys its grid-variant feature docs with a compact, unhyphenated + * component prefix taken from the docfx folder name (treegrid-filtering, + * hierarchicalgrid-paging, and camelCase pivotGrid-state-persistence), while + * the user-facing component name — and the DOC_ALIASES entry for it — is + * hyphenated (tree-grid). Composing a component and a topic therefore yields + * names like "tree-grid-filtering" that no doc uses. Rewriting the prefix + * resolves ~90 Angular docs that would otherwise fall through to the search + * fallback and land on a related-but-wrong doc (e.g. tree-grid-editing → + * treegrid-batch-editing). + * + * React, Web Components and Blazor key these docs with the hyphenated form + * (hierarchical-grid-advanced-filtering), so the rewrite is Angular-only. + */ +const ANGULAR_COMPACT_GRID_PREFIXES: Array<[string, string]> = [ + ['hierarchical-grid-', 'hierarchicalgrid-'], + ['tree-grid-', 'treegrid-'], + ['pivot-grid-', 'pivotGrid-'], +]; + +/** + * Rewrite a hyphenated Angular grid-variant prefix to its compact doc-key form. + * Returns null when no rewrite applies, so callers can skip the extra lookup. + */ +export function applyCompactGridPrefix(framework: string, name: string): string | null { + if (framework !== 'angular') return null; + for (const [hyphenated, compact] of ANGULAR_COMPACT_GRID_PREFIXES) { + if (name.startsWith(hyphenated)) { + return compact + name.slice(hyphenated.length); + } + } + return null; +} + +// Names that already carry a component-type prefix — skip the generic grid- retry for these. +const PREFIXED_DOC_RE = + /^(grid|hierarchical|tree|pivot|hierarchicalgrid|treegrid|pivotgrid|combo|drop-down|select|for-of)[-]/; + +/** + * Extract result doc names, in rank order, from searchDocs output. The two + * providers render results differently: + * local (LocalDocsProvider): `- **Toc Title** (`doc-name`)` — name is in the backticks + * remote (DocsController.cs): `**doc-name** [Component]` at line start, excerpt below + * A line with a backtick token uses that; otherwise a line-leading bold token is + * taken as the name. The bold form is restricted to doc-name characters so that + * bold prose inside a remote excerpt (`**Note:**`) is not mistaken for a result. + */ +export function parseDocNames(searchOutput: string): string[] { + const names: string[] = []; + for (const line of searchOutput.split(/\r?\n/)) { + const local = line.match(/\(`([^`]+)`\)/); + if (local) { + names.push(local[1]); + continue; + } + const remote = line.match(/^\*\*([A-Za-z0-9_.-]+)\*\*/); + if (remote) names.push(remote[1]); + } + return names; +} + +/** + * True when the requested name and a candidate doc name share a meaningful token + * (substring either direction, min 3 chars). Guards the search fallback against + * accepting an unrelated top hit — e.g. "textarea" → "grid-paste-excel" (no + * shared token, rejected) while still allowing "navigation-drawer" → "navdrawer" + * ("navdrawer" contains "drawer"). + */ +function sharesToken(requestName: string, docName: string): boolean { + requestName = requestName.toLowerCase(); + docName = docName.toLowerCase(); + const reqTokens = requestName.split('-').filter((t) => t.length >= 3); + const docTokens = docName.split('-').filter((t) => t.length >= 3); + return ( + reqTokens.some((t) => docName.includes(t)) || + docTokens.some((t) => requestName.includes(t)) + ); +} + +export interface ResolvedDoc { + text: string; + found: boolean; + servedName: string; + /** + * True when the doc was located by the full-text search fallback rather than + * by a deterministic name mapping. The served doc is only a best guess at + * what the caller meant, so callers should say so in their response. + */ + fuzzy: boolean; +} + +/** + * Resolve a caller-supplied doc name to actual doc content for get_doc. + * Applies normalizeDocName + applyDocAlias, then the Angular compact grid + * prefix rewrite (tree-grid-x → treegrid-x), then a generic grid- prefix + * fallback for bare feature names (e.g. "sorting" → "grid-sorting"). + * As a last resort, runs a full-text search and serves the top hit — this + * catches names that don't map mechanically (e.g. angular "navigation drawer" + * → navdrawer, angular charts under the types- prefix) and is the only path + * that sets fuzzy. + */ +export async function resolveDoc( + docsProvider: DocsProvider, + framework: string, + name: string, +): Promise { + const resolvedName = applyDocAlias(framework, normalizeDocName(name.trim())); + let { text, found } = await docsProvider.getDoc(framework, resolvedName); + let servedName = resolvedName; + + if (!found) { + const compactName = applyCompactGridPrefix(framework, resolvedName); + if (compactName) { + const rewritten = await docsProvider.getDoc(framework, compactName); + if (rewritten.found) { + ({ text, found } = rewritten); + servedName = compactName; + } + } + } + + if (!found && !PREFIXED_DOC_RE.test(resolvedName)) { + const withGridPrefix = await docsProvider.getDoc(framework, `grid-${resolvedName}`); + if (withGridPrefix.found) { + ({ text, found } = withGridPrefix); + servedName = `grid-${resolvedName}`; + } + } + + let fuzzy = false; + + if (!found) { + const query = sanitizeSearchDocsQuery(resolvedName.replace(/-/g, ' ')); + if (query) { + // A failed search must not turn a plain not-found into a tool error. + let results: string; + try { + results = await docsProvider.searchDocs(framework, query); + } catch { + return { text, found, servedName, fuzzy }; + } + // Accept the highest-ranked hit that shares a token with the request. + // Checking the top few (not just #1) recovers cases where the best hit + // ranks second, without accepting an unrelated doc. + const candidates = parseDocNames(results) + .slice(0, 5) + .filter((name) => sharesToken(resolvedName, name)); + for (const candidate of candidates) { + const hit = await docsProvider.getDoc(framework, candidate); + if (hit.found) { + ({ text, found } = hit); + servedName = candidate; + fuzzy = true; + break; + } + } + } + } + + return { text, found, servedName, fuzzy }; +} + +/** + * Notice prepended to a response whose doc came from the search fallback, so the + * caller can tell "here is the doc you asked for" apart from "here is the + * nearest thing I found". Without it a request for tree-grid-editing that lands + * on treegrid-batch-editing reads as an exact hit. + */ +export function formatSubstitutionNotice(requestedName: string, servedName: string): string { + return ( + `Note: no doc named \`${requestedName}\` exists — showing the closest match, \`${servedName}\`. ` + + `The content below may cover a different feature than requested; use list_components or search_docs to see other options.` + ); +} + // Build the setup-guide response for the requested framework. // For Blazor, combine the base .NET guide with any MCP-fetched docs // that are available for the configured setup document names. diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/handlers.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/handlers.ts index 039b24214..02fb2493b 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/handlers.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/handlers.ts @@ -21,32 +21,28 @@ export function createGetApiReferenceHandler(docLoader: ApiDocLoader) { } } - let resolvedComponent = component; - let entry = docLoader.get(platform, resolvedComponent); - - if (!entry) { - // Try case-insensitive search within platform - const results = docLoader.search({ platform, filter: resolvedComponent }); - const caseInsensitive = results.find( - e => e.component.toLowerCase() === resolvedComponent.toLowerCase() - ); + // Agents that read `member` as required send placeholders like "*" or ".*" + // when they want the whole entry. Anything with no letters or digits cannot + // name a real member, so treat it as omitted rather than failing. + if (member && !/[A-Za-z0-9]/.test(member)) { + member = undefined; + } - if (caseInsensitive) { - resolvedComponent = caseInsensitive.component; - entry = caseInsensitive; - } + // ApiDocLoader.get is exact-first, then case-insensitive and generic-stripped + // (IgbCombo → IgbCombo). + const entry = docLoader.get(platform, component); - if (!entry) { - const platformName = getPlatformConfig(platform).displayName; - return { - content: [{ - type: "text", - text: `API reference for "${resolvedComponent}" not found in ${platformName}. Use search_api to find available components.` - }], - isError: true, - }; - } + if (!entry) { + const platformName = getPlatformConfig(platform).displayName; + return { + content: [{ + type: "text", + text: `API reference for "${component}" not found in ${platformName}. Use search_api to find available components.` + }], + isError: true, + }; } + const resolvedComponent = entry.component; const content = entry.content; if (!content) { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/schemas.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/schemas.ts index 46a0988af..921dbeda3 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/schemas.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/schemas.ts @@ -16,10 +16,9 @@ export const getApiReferenceSchema = z.object({ member: z .string() .trim() - .min(1, 'Member name must not be empty') .max(MAX_COMPONENT_LENGTH, `Member name must be at most ${MAX_COMPONENT_LENGTH} characters`) .optional() - .describe('Optional member name (property, method, or event) to return only that entry instead of the full component. Examples: "checked", "click", "igcChange". Takes precedence over "section" when both are supplied.') + .describe('Optional member name (property, method, or event) to return only that entry instead of the full component. Examples: "checked", "click", "igcChange". Takes precedence over "section" when both are supplied. Omit it (do not pass an empty string) to get the full component or section; an empty/whitespace value is treated as omitted.') }); export const searchApiSchema = z.object({ diff --git a/spec/unit/mcp-runtime-spec.ts b/spec/unit/mcp-runtime-spec.ts index e6ad8a672..00f3446ab 100644 --- a/spec/unit/mcp-runtime-spec.ts +++ b/spec/unit/mcp-runtime-spec.ts @@ -231,23 +231,44 @@ describe("Unit - MCP runtime", () => { expect(result.content[0].text).toBe(apiContent); }); - it("falls back to case-insensitive component matching", async () => { + it("delegates fuzzy component matching to the loader and reports the resolved name", async () => { + // Case-insensitive and generic-stripped lookups live in ApiDocLoader.get; + // the handler uses whatever entry comes back. const docLoader = { - get: jasmine.createSpy().and.returnValue(undefined), - search: jasmine.createSpy().and.returnValue([ - { component: "IgcGridComponent", platform: "webcomponents", content: "# IgcGridComponent" } - ]) + get: jasmine.createSpy().and.returnValue({ + component: "IgcGridComponent", + platform: "webcomponents", + content: "# IgcGridComponent\n\n## Properties\n- height: string" + }), + search: jasmine.createSpy().and.returnValue([]) }; const handler = createGetApiReferenceHandler(docLoader); const result = await handler({ platform: "webcomponents", component: "igcgridcomponent", - section: "all" + section: "properties" }); - expect(docLoader.search).toHaveBeenCalledWith({ platform: "webcomponents", filter: "igcgridcomponent" }); + expect(docLoader.get).toHaveBeenCalledWith("webcomponents", "igcgridcomponent"); + expect(docLoader.search).not.toHaveBeenCalled(); expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("# IgcGridComponent (webcomponents) - properties"); + }); + + it("treats a blank or placeholder member as omitted", async () => { + const apiContent = "# IgrGrid\n\n## Properties\n- height: string\n\n## Methods\n- refresh(): void"; + const docLoader = { + get: jasmine.createSpy().and.returnValue({ component: "IgrGrid", platform: "react", content: apiContent }), + search: jasmine.createSpy().and.returnValue([]) + }; + const handler = createGetApiReferenceHandler(docLoader); + + for (const member of ["", ".*", "*"]) { + const result = await handler({ platform: "react", component: "IgrGrid", section: "all", member }); + expect(result.isError).withContext(`member=${JSON.stringify(member)}`).toBeUndefined(); + expect(result.content[0].text).toBe(apiContent); + } }); it("returns an error when an API reference is not found", async () => {