diff --git a/.changeset/compact-mcp-pattern-guidance.md b/.changeset/compact-mcp-pattern-guidance.md new file mode 100644 index 00000000000..b3bd6decb72 --- /dev/null +++ b/.changeset/compact-mcp-pattern-guidance.md @@ -0,0 +1,5 @@ +--- +'@primer/mcp': minor +--- + +MCP: Return compact structured pattern guidance with explicit component references and an opt-in full-detail mode. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6f199385a25..f6b203e9b00 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -56,6 +56,23 @@ observed relationship types are deterministically limited to the three strongest records and report omitted counts. `get_component` remains available when full package documentation or complete relationship provenance is needed. +## Pattern guidance + +`get_pattern` returns compact structured guidance by default. Its component and +related-pattern references are parsed from the authoritative Primer Style page +at request time: `/product/components/` links are reported as +`primer-public`, and `/product/internal-components/` links are reported as +`primer-internal`. This avoids a separate mapping catalog and keeps the result +current as the hosted page changes. + +Pass `detail: "full"` to receive the complete Primer Style guidance when +compact implementation, accessibility, and state guidance is insufficient. Full +guidance uses the pattern's text-only `llms.txt` endpoint. Until a hosted +pattern page has that endpoint, the server falls back to the existing HTML +conversion. The compact parser intentionally fails when it cannot identify the +page's guidance body, rather than returning navigation content as pattern +guidance. + ## 🙌 Contributing We love collaborating with folks inside and outside of GitHub and welcome contributions! If you're interested, check out our [contributing docs](contributor-docs/CONTRIBUTING.md) for more info on how to get started. diff --git a/packages/mcp/src/patterns.test.ts b/packages/mcp/src/patterns.test.ts new file mode 100644 index 00000000000..dfadf244459 --- /dev/null +++ b/packages/mcp/src/patterns.test.ts @@ -0,0 +1,86 @@ +import {describe, expect, it} from 'vitest' +import {formatCompactPatternDetails, getPatternUrl, parseCompactPatternDetails} from './patterns' +import {listPatterns} from './primer' + +const searchPatternPage = ` +
+
+

Search

+
+

+

Search finds things that match specific criteria without including navigation chrome.

+

Implementation guidelines

+

Use TextInput for a simple query.

+

Use the Filter component for tokenised qualifiers.

+

Loading results

+

Set aria-busy="true" while loading, and show an empty state when no results match.

+

Related scenario patterns

+ +
+
+
+` + +describe('compact pattern details', () => { + const patterns = listPatterns() + const search = patterns.find(pattern => pattern.name === 'Search') + + if (!search) throw new Error('Expected Search pattern') + + it('uses canonical Primer URLs for public and internal component references', () => { + const details = parseCompactPatternDetails(searchPatternPage, search, getPatternUrl(search), patterns) + + expect(details).toEqual({ + detail: 'compact', + pattern: { + id: 'search', + name: 'Search', + category: 'scenario', + sourceUrl: 'https://primer.style/product/scenario-patterns/search', + }, + summary: 'Search finds things that match specific criteria without including navigation chrome.', + components: [ + { + id: 'text-input', + name: 'TextInput', + source: 'primer-public', + sourceUrl: 'https://primer.style/product/components/text-input/', + }, + { + id: 'filter', + name: 'Filter', + source: 'primer-internal', + sourceUrl: 'https://primer.style/product/internal-components/filter/', + }, + ], + relatedPatterns: [ + { + id: 'filter', + name: 'Filter', + category: 'scenario', + sourceUrl: 'https://primer.style/product/scenario-patterns/filter/', + }, + ], + guidance: { + implementation: 'Use TextInput for a simple query. Use the Filter component for tokenised qualifiers.', + accessibility: ['Set aria-busy="true" while loading, and show an empty state when no results match.'], + states: ['Set aria-busy="true" while loading, and show an empty state when no results match.'], + }, + }) + }) + + it('keeps compact text below MCP truncation limits and excludes page chrome', () => { + const details = parseCompactPatternDetails(searchPatternPage, search, getPatternUrl(search), patterns) + const text = formatCompactPatternDetails(details) + + expect(text).not.toContain('Product UI / Scenario patterns') + expect(text.length).toBeLessThan(4_000) + expect(JSON.stringify(details).length).toBeLessThan(4_000) + }) + + it('rejects pages that do not have a discernible guidance body', () => { + expect(() => + parseCompactPatternDetails('

Search

', search, getPatternUrl(search), patterns), + ).toThrow('Unable to locate the pattern guidance content in the Primer Style page.') + }) +}) diff --git a/packages/mcp/src/patterns.ts b/packages/mcp/src/patterns.ts new file mode 100644 index 00000000000..754739bae92 --- /dev/null +++ b/packages/mcp/src/patterns.ts @@ -0,0 +1,272 @@ +// eslint-disable-next-line import/no-namespace +import * as cheerio from 'cheerio' +import type {Pattern} from './primer' + +type CheerioContent = ReturnType + +const maximumComponentReferences = 12 +const maximumRelatedPatterns = 6 +const maximumGuidanceItems = 2 +const maximumSummaryLength = 480 +const maximumGuidanceItemLength = 280 +const maximumImplementationLength = 900 + +type ComponentSource = 'primer-public' | 'primer-internal' + +export interface PatternReference { + id: string + name: string + category: Pattern['category'] + sourceUrl: string +} + +export interface PatternComponentReference { + id: string + name: string + source: ComponentSource + sourceUrl: string +} + +export interface PatternGuidance { + implementation: string + accessibility: Array + states: Array +} + +export interface CompactPatternDetails extends Record { + detail: 'compact' + pattern: PatternReference + summary: string + components: Array + relatedPatterns: Array + guidance: PatternGuidance +} + +export interface FullPatternDetails extends Record { + detail: 'full' + pattern: PatternReference +} + +export type PatternDetails = CompactPatternDetails | FullPatternDetails + +export function getPatternUrl(pattern: Pattern): URL { + const basePath = pattern.category === 'scenario' ? 'scenario-patterns' : 'ui-patterns' + return new URL(`/product/${basePath}/${pattern.id}`, 'https://primer.style') +} + +export function parseCompactPatternDetails( + html: string, + pattern: Pattern, + sourceUrl: URL, + patterns: Array, +): CompactPatternDetails { + const $ = cheerio.load(html) + const content = getPatternContent($) + + return { + detail: 'compact', + pattern: toPatternReference(pattern, sourceUrl), + summary: truncate( + getText( + content + .find('p') + .filter((_, element) => getText($(element)).length > 0) + .first(), + ), + maximumSummaryLength, + ), + components: getComponentReferences($, content, sourceUrl), + relatedPatterns: getRelatedPatterns($, content, sourceUrl, patterns), + guidance: { + implementation: getImplementationGuidance($, content), + accessibility: getGuidanceByKeyword($, content, /aria-|screen reader|assistive|keyboard|focus/i), + states: getGuidanceByKeyword($, content, /loading|empty|error|state|recover/i), + }, + } +} + +export function toFullPatternDetails(pattern: Pattern, sourceUrl: URL): FullPatternDetails { + return { + detail: 'full', + pattern: toPatternReference(pattern, sourceUrl), + } +} + +export function formatCompactPatternDetails(details: CompactPatternDetails): string { + const componentLines = details.components.map(component => { + return `- ${component.name} (${component.source}): ${component.sourceUrl}` + }) + const relatedPatternLines = details.relatedPatterns.map(pattern => { + return `- ${pattern.name} (${pattern.category}): ${pattern.sourceUrl}` + }) + const guidanceLines = [ + details.guidance.implementation && `Implementation: ${details.guidance.implementation}`, + ...details.guidance.accessibility.map(guidance => `Accessibility: ${guidance}`), + ...details.guidance.states.map(guidance => `States: ${guidance}`), + ].filter((line): line is string => Boolean(line)) + + return [ + `## ${details.pattern.name} (${details.pattern.category} pattern)`, + details.summary, + `Source: ${details.pattern.sourceUrl}`, + componentLines.length > 0 ? `Components:\n${componentLines.join('\n')}` : undefined, + relatedPatternLines.length > 0 ? `Related patterns:\n${relatedPatternLines.join('\n')}` : undefined, + guidanceLines.length > 0 ? `Guidance:\n${guidanceLines.map(line => `- ${line}`).join('\n')}` : undefined, + 'Pass `detail: "full"` for the complete Primer Style guidance.', + ] + .filter((section): section is string => Boolean(section)) + .join('\n\n') +} + +function getPatternContent($: cheerio.CheerioAPI) { + const main = $('main').first() + const heading = main.find('h1').first() + const pageContent = heading.parent().parent() + const content = pageContent + .children() + .filter((_, element) => $(element).find('h2').length > 0) + .first() + + if (!main.length || !heading.length || !content.length) { + throw new Error('Unable to locate the pattern guidance content in the Primer Style page.') + } + + return content +} + +function getComponentReferences( + $: cheerio.CheerioAPI, + content: CheerioContent, + sourceUrl: URL, +): Array { + const references = new Map() + + content.find('a[href]').each((_, element) => { + const href = $(element).attr('href') + const reference = href ? toComponentReference(href, getText($(element)), sourceUrl) : undefined + if (!reference) return + + const current = references.get(reference.sourceUrl) + if (!current || (href && !href.includes('#') && current.name !== reference.name)) { + references.set(reference.sourceUrl, reference) + } + }) + + return [...references.values()].slice(0, maximumComponentReferences) +} + +function getRelatedPatterns( + $: cheerio.CheerioAPI, + content: CheerioContent, + sourceUrl: URL, + patterns: Array, +): Array { + const heading = content + .find('h2') + .filter((_, element) => /related .*patterns/i.test(getText($(element)))) + .first() + if (!heading.length) return [] + + const references = new Map() + heading + .nextUntil('h2') + .find('a[href]') + .each((_, element) => { + const href = $(element).attr('href') + if (!href) return + + const reference = toPatternReferenceFromUrl(href, sourceUrl, patterns) + if (reference) references.set(reference.sourceUrl, reference) + }) + + return [...references.values()].slice(0, maximumRelatedPatterns) +} + +function getImplementationGuidance($: cheerio.CheerioAPI, content: CheerioContent): string { + const heading = content + .find('h2') + .filter((_, element) => /implementation/i.test(getText($(element)))) + .first() + if (!heading.length) return '' + + return truncate(getTextFromElements($, heading.nextUntil('h2'), maximumGuidanceItems), maximumImplementationLength) +} + +function getGuidanceByKeyword($: cheerio.CheerioAPI, content: CheerioContent, keyword: RegExp): Array { + const guidance = new Set() + + content.find('p, li').each((_, element) => { + const text = getText($(element)) + if (keyword.test(text)) guidance.add(truncate(text, maximumGuidanceItemLength)) + }) + + return [...guidance].slice(0, maximumGuidanceItems) +} + +function getTextFromElements($: cheerio.CheerioAPI, elements: CheerioContent, maximumItems: number): string { + const text = new Set() + + elements.each((_, element) => { + const section = $(element) + if (section.is('p, li')) text.add(getText(section)) + + section.find('p, li').each((_, child) => { + text.add(getText($(child))) + }) + }) + + return [...text].filter(Boolean).slice(0, maximumItems).join(' ') +} + +function toPatternReference(pattern: Pattern, sourceUrl: URL): PatternReference { + return { + id: pattern.id, + name: pattern.name, + category: pattern.category, + sourceUrl: sourceUrl.toString(), + } +} + +function toPatternReferenceFromUrl( + href: string, + sourceUrl: URL, + patterns: Array, +): PatternReference | undefined { + const url = new URL(href, sourceUrl) + if (url.origin !== 'https://primer.style') return undefined + + const match = url.pathname.match(/^\/product\/(scenario-patterns|ui-patterns)\/([^/]+)\/?$/) + if (!match) return undefined + + const category = match[1] === 'scenario-patterns' ? 'scenario' : 'ui' + const id = decodeURIComponent(match[2]) + const pattern = patterns.find(candidate => candidate.id === id && candidate.category === category) + if (!pattern) return undefined + + return toPatternReference(pattern, new URL(url.pathname, url)) +} + +function toComponentReference(href: string, name: string, sourceUrl: URL): PatternComponentReference | undefined { + const url = new URL(href, sourceUrl) + if (url.origin !== 'https://primer.style') return undefined + + const match = url.pathname.match(/^\/product\/(components|internal-components)\/([^/]+)\/?$/) + if (!match) return undefined + + return { + id: decodeURIComponent(match[2]), + name, + source: match[1] === 'components' ? 'primer-public' : 'primer-internal', + sourceUrl: new URL(url.pathname, url).toString(), + } +} + +function getText(element: CheerioContent): string { + return element.text().replace(/\s+/g, ' ').trim() +} + +function truncate(value: string, maximumLength: number): string { + if (value.length <= maximumLength) return value + + return `${value.slice(0, maximumLength - 1).trimEnd()}…` +} diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index 113305f9889..df5dedc8fe4 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -203,7 +203,7 @@ function includesComponent(relation: ComponentRelationship, componentName: strin }) } -type Pattern = { +export interface Pattern { id: string name: string // 'scenario' maps to the /product/scenario-patterns/ base path, 'ui' to /product/ui-patterns/ diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 77b0d695adc..a98b05f5a50 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -4,6 +4,23 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} fr import {getComponentDocsSource} from './primer' import {server} from './server' +const searchPatternPage = ` +
+
+

Search

+
+

Search finds things that match specific criteria.

+

Implementation guidelines

+

Use TextInput for a simple query.

+

Use the Filter component for tokenised qualifiers.

+

Set aria-busy="true" while loading, and show an empty state when no results match.

+

Related scenario patterns

+ +
+
+
+` + describe('component documentation sources', () => { it('uses hosted documentation by default and supports package metadata', () => { expect(getComponentDocsSource(undefined)).toBe('hosted') @@ -43,6 +60,12 @@ describe('get_component_batch', () => { arguments: {names, source}, }) } + const callPattern = (name: string, detail?: 'compact' | 'full') => { + return client.callTool({ + name: 'get_pattern', + arguments: {name, detail}, + }) + } it('requires between 2 and 10 names', async () => { const tooFew = await callBatch(['Button']) @@ -198,6 +221,83 @@ describe('get_component_batch', () => { ) }) + it('returns compact structured pattern guidance with explicit component sources', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(searchPatternPage)) + + const result = await callPattern('Search') + const text = getTextContent(result) + + expect(result.structuredContent).toMatchObject({ + detail: 'compact', + pattern: { + id: 'search', + name: 'Search', + category: 'scenario', + sourceUrl: 'https://primer.style/product/scenario-patterns/search', + }, + components: [ + { + id: 'text-input', + name: 'TextInput', + source: 'primer-public', + sourceUrl: 'https://primer.style/product/components/text-input/', + }, + { + id: 'filter', + name: 'Filter', + source: 'primer-internal', + sourceUrl: 'https://primer.style/product/internal-components/filter/', + }, + ], + relatedPatterns: [{id: 'filter', name: 'Filter', category: 'scenario'}], + guidance: { + implementation: expect.stringContaining('Use TextInput'), + accessibility: [expect.stringContaining('aria-busy')], + states: [expect.stringContaining('loading')], + }, + }) + expect(text).not.toContain('Product UI / Scenario patterns') + expect(text).toContain('Pass `detail: "full"`') + expect(text.length).toBeLessThan(4_000) + expect(JSON.stringify(result.structuredContent).length).toBeLessThan(4_000) + }) + + it('preserves complete pattern guidance when full detail is requested', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('# Search\n\nText-only pattern guidance.')) + + const result = await callPattern('Search', 'full') + + expect(result.structuredContent).toEqual({ + detail: 'full', + pattern: { + id: 'search', + name: 'Search', + category: 'scenario', + sourceUrl: 'https://primer.style/product/scenario-patterns/search', + }, + }) + expect(fetch).toHaveBeenCalledWith(new URL('https://primer.style/product/scenario-patterns/search/llms.txt')) + expect(getTextContent(result)).toBe('# Search\n\nText-only pattern guidance.') + }) + + it('reports missing, empty, and invalid pattern requests without fetching unrelated content', async () => { + const missing = await callPattern('Missing') + const invalidDetail = await client.callTool({ + name: 'get_pattern', + arguments: {name: 'Search', detail: 'summary'}, + }) + + expect(getTextContent(missing)).toContain('There is no pattern named `Missing`') + expect(invalidDetail.isError).toBe(true) + expect(fetch).not.toHaveBeenCalled() + + vi.mocked(fetch).mockResolvedValue(new Response('')) + const empty = await callPattern('Search') + + expect(empty.isError).toBe(true) + expect(getTextContent(empty)).toContain('Primer Style returned an empty page') + }) + it('times out stalled requests without leaving timers active', async () => { vi.useFakeTimers() const fetchMock = vi.mocked(fetch) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 41b98c40b26..81a8bf87a31 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -14,6 +14,7 @@ import { listPatterns, listIcons, } from './primer' +import {formatCompactPatternDetails, getPatternUrl, parseCompactPatternDetails, toFullPatternDetails} from './patterns' import { listTokenGroups, loadAllTokensWithGuidelines, @@ -38,6 +39,32 @@ const server = new McpServer({ const turndownService = new TurndownService() const defaultComponentDocsSource = getComponentDocsSource() +const patternReferenceOutputSchema = z.object({ + id: z.string(), + name: z.string(), + category: z.enum(['scenario', 'ui']), + sourceUrl: z.string().url(), +}) +const patternComponentOutputSchema = z.object({ + id: z.string(), + name: z.string(), + source: z.enum(['primer-public', 'primer-internal']), + sourceUrl: z.string().url(), +}) +const patternOutputSchema = z.object({ + detail: z.enum(['compact', 'full']), + pattern: patternReferenceOutputSchema, + summary: z.string().optional(), + components: z.array(patternComponentOutputSchema).optional(), + relatedPatterns: z.array(patternReferenceOutputSchema).optional(), + guidance: z + .object({ + implementation: z.string(), + accessibility: z.array(z.string()), + states: z.array(z.string()), + }) + .optional(), +}) // Load all tokens with guidelines from primitives const allTokensWithGuidelines: TokenWithGuidelines[] = loadAllTokensWithGuidelines() @@ -567,13 +594,19 @@ server.registerTool( 'get_pattern', { description: - 'Get a specific pattern by name. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.', + 'Get a specific pattern by name. Returns compact structured guidance by default, including authoritative component and related-pattern links. Pass detail: "full" for the complete Primer Style page. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.', inputSchema: { name: z.string().describe('The name of the pattern to retrieve'), + detail: z + .enum(['compact', 'full']) + .optional() + .default('compact') + .describe('Response detail: compact structured guidance (default) or full Primer Style guidance'), }, + outputSchema: patternOutputSchema, annotations: {readOnlyHint: true}, }, - async ({name}) => { + async ({name, detail}) => { const patterns = listPatterns() // Resolve scenario patterns first so a name clash favours the scenario pattern. const match = @@ -581,6 +614,7 @@ server.registerTool( patterns.find(pattern => pattern.name === name) if (!match) { return { + isError: true, content: [ { type: 'text', @@ -590,8 +624,32 @@ server.registerTool( } } - const basePath = match.category === 'scenario' ? 'scenario-patterns' : 'ui-patterns' - const url = new URL(`/product/${basePath}/${match.id}`, 'https://primer.style') + const url = getPatternUrl(match) + + if (detail === 'full') { + const llmsUrl = new URL(`${url.pathname}/llms.txt`, url) + const llmsResponse = await fetch(llmsUrl) + + if (llmsResponse.ok) { + const text = await llmsResponse.text() + if (!text) { + return { + isError: true, + content: [{type: 'text', text: `Primer Style returned an empty page for the \`${name}\` pattern.`}], + } + } + + return { + structuredContent: toFullPatternDetails(match, url), + content: [{type: 'text', text}], + } + } + + if (llmsResponse.status !== 404) { + throw new Error(`Failed to fetch ${llmsUrl} - ${llmsResponse.statusText}`) + } + } + const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url} - ${response.statusText}`) @@ -600,7 +658,8 @@ server.registerTool( const html = await response.text() if (!html) { return { - content: [], + isError: true, + content: [{type: 'text', text: `Primer Style returned an empty page for the \`${name}\` pattern.`}], } } @@ -608,19 +667,35 @@ server.registerTool( const source = $('main').html() if (!source) { return { - content: [], + isError: true, + content: [{type: 'text', text: `Primer Style did not return pattern guidance for \`${name}\`.`}], } } - const text = turndownService.turndown(source) + if (detail === 'full') { + const details = toFullPatternDetails(match, url) + const text = turndownService.turndown(source) + + return { + structuredContent: details, + content: [ + { + type: 'text', + text: `Here are the guidelines for the \`${name}\` pattern for Primer: + +${text}`, + }, + ], + } + } + const details = parseCompactPatternDetails(html, match, url, patterns) return { + structuredContent: details, content: [ { type: 'text', - text: `Here are the guidelines for the \`${name}\` pattern for Primer: - -${text}`, + text: formatCompactPatternDetails(details), }, ], }