Skip to content
Merged
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
22 changes: 11 additions & 11 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ from a checkout of `koobiq/angular-components`.

## Tools

| Tool | Answers |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `search` | one ranked index over entry points, symbols, members, examples, icons, tokens and guides, in English and Russian |
| `get_component` | inputs under the names a template writes, outputs, selectors, `exportAs` |
| `get_example` | source of one live example from the documentation site |
| `get_icon` | usable attribute value and markup for an icon |
| `get_token` | CSS custom property, value per theme, deprecation status |
| `get_guide` | installation, theming, localization, validation, schematics, smart search, migration — narrowed in three steps, in either language |
| `migrate` | what changed between two versions, which entry points lost API, where the written instructions are |
| `which_component_when` | which component answers a task, and which neighbour would be the wrong choice — with the reason |
| `list_components` | the complete list of documented areas, as ids the other tools accept |
| Tool | Answers |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `search` | one ranked index over entry points, symbols, members, examples, icons, tokens and guides, in English and Russian |
| `get_component` | inputs under the names a template writes, outputs, selectors, `exportAs` |
| `get_example` | source of one live example from the documentation site |
| `get_icon` | usable attribute value and markup for an icon |
| `get_token` | CSS custom property, value per theme, deprecation status |
| `get_guide` | installation, theming, localization, validation, schematics, smart search, migration — narrowed in three steps |
| `migrate` | what changed between two versions, which entry points lost API, where the written instructions are |
| `which_component_when` | which component answers a task, and which neighbour would be the wrong choice — with the reason |
| `list_components` | the complete list of documented areas, as ids the other tools accept |

## Using it on a project

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/data/guides.json

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions packages/mcp/src/indexer/build-guides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ const WITHHELD: Record<string, string> = {
'component-health': 'internal quality tracking'
};

const MATCHED_IN_RUSSIAN = new Set(['which-component-when']);

const INTERNAL = /^\d{2}-/;
// `.claude/rules/schematics.md` shares its name with the `schematics` guide
const HIDDEN_DIR = /(^|\/)\.[^/]+\//;
const GUIDE_FILE = /(?:^|\/)([a-z0-9-]+?)(?:\.(en|ru))?\.mdx?$/;

/** `lang` is null when the file name does not say; the content decides. */
Expand All @@ -68,7 +72,7 @@ const locate = (paths: string[]): Located[] => {
const found: Located[] = [];

for (const path of paths) {
if (INTERNAL.test(basename(path))) continue;
if (INTERNAL.test(basename(path)) || HIDDEN_DIR.test(path)) continue;

const match = GUIDE_FILE.exec(path);
const id = match?.[1];
Expand Down Expand Up @@ -160,7 +164,11 @@ export const buildGuides = (repo: string): Guide[] => {
const lang = named ?? detectLang(source);
const key = `${id}\t${lang}`;

if (guides.has(key)) return;
// Only the English text ships, except where the Russian text is matched against rather than
// read: an agent reads English as well as Russian and answers in the user's language, so a
// second copy would only double the size. `which_component_when` matches the words of a task
// against the guide's rows, and a Russian task has nothing to hit in the English ones.
if ((lang !== 'en' && !MATCHED_IN_RUSSIAN.has(id)) || guides.has(key)) return;

const { lede, sections } = parseGuide(source);
const trimmed = trimMigration(id, sections);
Expand Down Expand Up @@ -204,8 +212,8 @@ export const buildGuides = (repo: string): Guide[] => {
// until someone remembers to commit. Nothing arbitrary gets in: `locate` only returns ids that
// `PUBLIC_GUIDES` names, and everything taken here is flagged `unreleased`.
const onDisk = [
...git(repo, ['ls-files']).split('\n'),
...git(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')
...git(repo, ['ls-files', 'docs/guides']).split('\n'),
...git(repo, ['ls-files', '--others', '--exclude-standard', 'docs/guides']).split('\n')
].filter(Boolean);

for (const { id, lang, path } of locate(onDisk)) {
Expand Down Expand Up @@ -238,7 +246,7 @@ const main = (): void => {
[
outputPath,
` from release ${latestRelease(repo) ?? 'none — working tree only'}`,
` guides ${guides.length} (${new Set(guides.map((guide) => guide.id)).size} ids x languages)`,
` guides ${guides.length}`,
` sections ${sections}`,
` size ${Math.round(Buffer.byteLength(JSON.stringify(guides)) / 1024)} KB`,
` built in ${((Date.now() - started) / 1000).toFixed(1)}s`,
Expand Down
6 changes: 2 additions & 4 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ const register = (server: McpServer, catalog: Catalog): void => {
section: z
.string()
.optional()
.describe('Section heading, exact or partial; falls back to a keyword from its text'),
lang: z.enum(['en', 'ru']).optional().describe('Preferred language, default en')
.describe('Section heading, exact or partial; falls back to a keyword from its text')
}
},
async (args) => text(toolGetGuide(catalog, args))
Expand All @@ -134,8 +133,7 @@ const register = (server: McpServer, catalog: Catalog): void => {
inputSchema: {
from: z.string().describe('Version being upgraded from, e.g. "18.4.0"'),
to: z.string().optional().describe('Version being upgraded to; defaults to the installed one'),
component: z.string().optional().describe('List this entry point\'s changes, e.g. "select"'),
lang: z.enum(['en', 'ru']).optional().describe('Language of the migration guide, default en')
component: z.string().optional().describe('List this entry point\'s changes, e.g. "select"')
}
},
async (args) => text(toolMigrate(catalog, args))
Expand Down
41 changes: 11 additions & 30 deletions packages/mcp/src/sources/guides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export const guideCaveat = (guide: Guide, version: string | null): string | null
return `this text is from ${guide.since} and this project is on ${version} — a different major, so parts of it may not apply`;
};

/** Prefers the asked-for language, falls back to the other rather than answering nothing. */
export const pickGuide = (guides: Guide[], id: string, lang: 'en' | 'ru'): Guide | null => {
/** English unless another language is asked for; falls back to what exists rather than answering nothing. */
export const pickGuide = (guides: Guide[], id: string, lang: 'en' | 'ru' = 'en'): Guide | null => {
const matching = guides.filter((guide) => guide.id === id);

return matching.find((guide) => guide.lang === lang) ?? matching[0] ?? null;
Expand Down Expand Up @@ -77,7 +77,7 @@ export const sectionPath = (guide: Guide, index: number): string => {
};

export type SectionHit =
| { kind: 'found'; section: GuideSection; path: string; viaLang?: 'en' | 'ru' }
| { kind: 'found'; section: GuideSection; path: string }
| { kind: 'ambiguous'; paths: string[] }
| { kind: 'missing' };

Expand Down Expand Up @@ -144,36 +144,17 @@ const locate = (guide: Guide, query: string): number[] => {
return body;
};

/**
* Resolves a section address, across languages when it does not resolve in this one.
*
* Headings are translated along with the text, so an address taken from `migrate` or from an
* English listing matches nothing in the Russian guide — four of the thirty-six calls in the first
* recorded session were spent discovering that one at a time. Both languages are the same document
* section for section, which is asserted in the tests, so a unique hit in the other language names
* the section here by position.
*/
export const findSection = (guides: Guide[], guide: Guide, query: string): SectionHit => {
/** Resolves a section address within one guide. */
export const findSection = (guide: Guide, query: string): SectionHit => {
const here = locate(guide, query);
const found = (index: number, viaLang?: 'en' | 'ru'): SectionHit => {
const section = guide.sections[index];

return section
? { kind: 'found', section, path: sectionPath(guide, index), ...(viaLang ? { viaLang } : {}) }
: { kind: 'missing' };
};

if (here.length === 1) return found(here[0]!);
if (here.length > 1) return { kind: 'ambiguous', paths: here.map((index) => sectionPath(guide, index)) };

const other = guides.find((candidate) => candidate.id === guide.id && candidate.lang !== guide.lang);
if (here.length === 1) {
const section = guide.sections[here[0]!];

// Position only means something while the two texts have the same sections. They do today, and
// a test says so; if one language gains a heading, answering from the other would hand back a
// neighbouring section while looking exactly like a correct answer.
if (!other || other.sections.length !== guide.sections.length) return { kind: 'missing' };
return section ? { kind: 'found', section, path: sectionPath(guide, here[0]!) } : { kind: 'missing' };
}

const there = locate(other, query);
if (here.length > 1) return { kind: 'ambiguous', paths: here.map((index) => sectionPath(guide, index)) };

return there.length === 1 ? found(there[0]!, other.lang) : { kind: 'missing' };
return { kind: 'missing' };
};
22 changes: 7 additions & 15 deletions packages/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,8 @@ export const toolGetToken = (catalog: Catalog, args: { name: string }): string =
* and `migration` is 200 KB, so returning a whole guide would cost more context than the answer
* is worth, and most questions are answered by one section.
*/
export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: string; lang?: 'en' | 'ru' }): string => {
export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: string }): string => {
const guides = catalog.guides();
const lang = args.lang ?? 'en';
const version = catalog.project.packages.get('@koobiq/components')?.version ?? null;

if (guides.length === 0) {
Expand All @@ -495,7 +494,7 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st

if (!args.id) {
const lines = guideIds(guides).flatMap((id) => {
const guide = pickGuide(guides, id, lang);
const guide = pickGuide(guides, id);

if (!guide) return [];

Expand All @@ -510,7 +509,7 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st
return [...lines, '', `Call again with id to list a guide's sections.`].join('\n');
}

const guide = pickGuide(guides, args.id, lang);
const guide = pickGuide(guides, args.id);

if (!guide) {
return [`Unknown guide "${args.id}".`, `Available: ${guideIds(guides).join(', ')}`].join('\n');
Expand All @@ -521,8 +520,7 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st
const provenance = [
`Guide: ${guide.title} (${guide.lang})`,
guide.unreleased ? null : `Text as shipped in ${guide.since}`,
caveat ? `NOTE: ${caveat}` : null,
guide.lang !== lang ? `Only the ${guide.lang} text is indexed for this guide.` : null
caveat ? `NOTE: ${caveat}` : null
].filter((line): line is string => line !== null);

// Listed as addresses rather than as bare headings: a repeated heading is not an address, and
Expand All @@ -542,7 +540,7 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st
].join('\n');
}

const hit = findSection(guides, guide, args.section);
const hit = findSection(guide, args.section);

if (hit.kind === 'ambiguous') {
return [
Expand All @@ -567,9 +565,6 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st
return [
...provenance,
`Section: ${hit.path}`,
// The caller asked in one language and was answered from the other's headings. Worth a
// line: it tells them which spelling to use next time, and that the text below is right.
hit.viaLang ? `Addressed by its ${hit.viaLang} heading; the ${guide.lang} text is below.` : null,
'',
section.body,
live.length > 0 ? `\nRunnable examples: ${live.join(', ')} — fetch with get_example.` : '',
Expand All @@ -589,10 +584,7 @@ export const toolGetGuide = (catalog: Catalog, args: { id?: string; section?: st
* The default answer is a summary, because the raw diff is not readable: 18.4.0 to 19.0.0 is 2930
* changes across 61 entry points. Removals lead — they are what stops compiling.
*/
export const toolMigrate = (
catalog: Catalog,
args: { from: string; to?: string; component?: string; lang?: 'en' | 'ru' }
): string => {
export const toolMigrate = (catalog: Catalog, args: { from: string; to?: string; component?: string }): string => {
const history = catalog.history();
const versions = Object.keys(history);

Expand Down Expand Up @@ -623,7 +615,7 @@ export const toolMigrate = (
.join('\n');
}

const guide = pickGuide(catalog.guides(), 'migration', args.lang ?? 'en');
const guide = pickGuide(catalog.guides(), 'migration');
const { dated, undated } = migrationSections(guide, range);

if (args.component) {
Expand Down
Loading
Loading