diff --git a/.changeset/mcp-internal-catalog.md b/.changeset/mcp-internal-catalog.md index 3cbc4e3af35..ce74d671d61 100644 --- a/.changeset/mcp-internal-catalog.md +++ b/.changeset/mcp-internal-catalog.md @@ -2,4 +2,4 @@ '@primer/mcp': minor --- -MCP: Add documented Primer-internal catalog discovery and opt-in GitHub-only recommendation candidates. +MCP: Add documented Primer-internal catalog discovery, composition-aware component recommendations, and efficient CSS validation. diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index df5dedc8fe4..b9c7f95d540 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -51,8 +51,24 @@ interface ComponentsMetadata { composition?: CompositionMetadata } +type ComponentIntentTerm = string | {all: Array} + const metadata: ComponentsMetadata = componentsMetadata const maximumObservedRelationshipsPerKind = 3 +const componentIntentTerms = new Map>([ + ['counter_label', ['count', 'metric', 'stat', 'statistic', 'total']], + [ + 'page_layout', + [{all: ['sidebar', 'detail']}, {all: ['sidebar', 'pane']}, {all: ['master', 'detail']}, {all: ['split', 'pane']}], + ], + ['nav_list', [{all: ['sidebar', 'navigation']}, {all: ['sidebar', 'navigator']}]], + [ + 'action_list', + [{all: ['action', 'row']}, {all: ['action', 'list']}, {all: ['action', 'item']}, {all: ['action', 'choice']}], + ], + ['avatar', ['avatar', 'member', 'people', 'person']], + ['label', ['badge', 'category', 'tag']], +]) function idToSlug(id: string): string { if (id === 'actionbar') { @@ -156,6 +172,10 @@ function getComponentCompositionSummary(id: string) { } } +function getComponentRecommendationTerms(id: string): Array { + return componentIntentTerms.get(id) ?? [] +} + function summarizeObservedRelationships(relationships: Array) { const orderedRelationships = [...relationships].sort((first, second) => { return ( @@ -309,6 +329,7 @@ function listIcons(): Array { export { getComponentComposition, getComponentCompositionSummary, + getComponentRecommendationTerms, getComponentDocsSource, getComponentDocument, getComponentSummary, diff --git a/packages/mcp/src/recommendations.test.ts b/packages/mcp/src/recommendations.test.ts index 84a1785b676..c78917fc4f2 100644 --- a/packages/mcp/src/recommendations.test.ts +++ b/packages/mcp/src/recommendations.test.ts @@ -1,9 +1,18 @@ import {describe, expect, it} from 'vitest' import type {CompactPatternDetails} from './patterns' -import {createRecommendation, formatRecommendation, rankPatterns, type RecommendationComponent} from './recommendations' -import {listPatterns} from './primer' +import { + createRecommendation, + formatRecommendation, + rankPatterns, + type RecommendationComponent, + type RecommendationIntentTerm, +} from './recommendations' const sourceUrl = 'https://primer.style/product/components/text-input' +const availablePatterns = [ + {id: 'filter', name: 'Filter', category: 'scenario' as const}, + {id: 'search', name: 'Search', category: 'scenario' as const}, +] const searchDetails: CompactPatternDetails = { detail: 'compact', pattern: { @@ -108,11 +117,72 @@ const internalCatalog = { ], }, } +function component( + id: string, + name: string, + intentTerms?: Array, + composition?: RecommendationComponent['composition'], +): RecommendationComponent { + return { + id, + name, + importPath: '@primer/react', + sourceUrl: `https://primer.style/product/components/${id.replaceAll('_', '-')}`, + searchTerms: [name], + ...(intentTerms ? {intentTerms} : {}), + ...(composition ? {composition} : {}), + } +} + +const emptyComposition = { + apiParentChild: [], + apiSubcomponents: [], + observed: {parentChild: [], adjacentSibling: [], variants: [], relatedComponents: []}, +} +const compositionComponents: Array = [ + component('counter_label', 'CounterLabel', ['count', 'metric', 'stat', 'statistic', 'total']), + component( + 'page_layout', + 'PageLayout', + [{all: ['sidebar', 'detail']}, {all: ['sidebar', 'pane']}, {all: ['master', 'detail']}, {all: ['split', 'pane']}], + { + ...emptyComposition, + observed: { + ...emptyComposition.observed, + parentChild: [{parent: 'PageLayout.Pane', child: 'NavList', sourceCount: 3}], + }, + }, + ), + component('nav_list', 'NavList', [{all: ['sidebar', 'navigation']}, {all: ['sidebar', 'navigator']}]), + component( + 'action_list', + 'ActionList', + [{all: ['action', 'row']}, {all: ['action', 'list']}, {all: ['action', 'item']}, {all: ['action', 'choice']}], + { + ...emptyComposition, + observed: { + ...emptyComposition.observed, + parentChild: [ + {parent: 'ActionList.LeadingVisual', child: 'Avatar', sourceCount: 3}, + {parent: 'ActionList.TrailingVisual', child: 'Label', sourceCount: 3}, + ], + }, + }, + ), + component('avatar', 'Avatar', ['avatar', 'member', 'people', 'person']), + component('label', 'Label', ['badge', 'category', 'tag']), + component('details', 'Details'), + component('text', 'Text'), + component('text_input', 'TextInput'), + component('action_menu', 'ActionMenu'), + component('pagination', 'Pagination'), + component('select', 'Select'), +] describe('component recommendations', () => { it('matches simple intent to authoritative pattern-linked public components', () => { const input = {intent: 'Add a search query'} - const result = createRecommendation(input, rankPatterns(listPatterns(), input), [searchDetails], components) + const result = createRecommendation(input, rankPatterns(availablePatterns, input), [searchDetails], components) expect(result.status).toBe('matched') expect(result.patterns[0]?.pattern.name).toBe('Search') @@ -124,7 +194,7 @@ describe('component recommendations', () => { const input = {intent: 'search', patternHints: ['filter']} const result = createRecommendation( input, - rankPatterns(listPatterns(), input), + rankPatterns(availablePatterns, input), [searchDetails, filterDetails], components, ) @@ -135,12 +205,112 @@ describe('component recommendations', () => { it('expands source-derived composition relationships without inventing mappings', () => { const input = {intent: 'filter'} - const result = createRecommendation(input, rankPatterns(listPatterns(), input), [filterDetails], components) + const result = createRecommendation(input, rankPatterns(availablePatterns, input), [filterDetails], components) expect(result.components.map(candidate => candidate.component.name)).toEqual(['ActionMenu', 'ActionList']) expect(result.components[1]?.evidence[0]).toMatchObject({sourceKind: 'composition'}) }) + it('recommends capability and composition metadata for generic assemblies', () => { + const metricIntent = {intent: 'Show a stat summary strip'} + const detailIntent = {intent: 'Build a parent detail navigation layout with a sidebar'} + const suggestionIntent = {intent: 'Show suggested people in action rows with avatars and status tags'} + + const metric = createRecommendation( + metricIntent, + rankPatterns(availablePatterns, metricIntent), + [], + compositionComponents, + ) + const detail = createRecommendation( + detailIntent, + rankPatterns(availablePatterns, detailIntent), + [], + compositionComponents, + ) + const suggestions = createRecommendation( + suggestionIntent, + rankPatterns(availablePatterns, suggestionIntent), + [], + compositionComponents, + ) + + expect(metric).toMatchObject({status: 'matched', patterns: []}) + expect(metric.components.map(candidate => candidate.component.name)).toContain('CounterLabel') + expect(detail.components.map(candidate => candidate.component.name)).toEqual( + expect.arrayContaining(['PageLayout', 'NavList']), + ) + expect(suggestions.components.map(candidate => candidate.component.name)).toEqual( + expect.arrayContaining(['ActionList', 'Avatar', 'Label']), + ) + expect(suggestions.components.flatMap(candidate => candidate.evidence)).toContainEqual( + expect.objectContaining({sourceKind: 'composition'}), + ) + }) + + it('requires full component names and grouped capability signals for direct metadata matches', () => { + const noPatterns: Array<{id: string; name: string; category: 'scenario'}> = [] + const getComponents = (intent: string) => + createRecommendation({intent}, rankPatterns(noPatterns, {intent}), [], compositionComponents).components.map( + candidate => candidate.component.name, + ) + + expect(getComponents('Use ActionList for these commands')).toContain('ActionList') + expect(getComponents('Use an ActionMenu for these commands')).toContain('ActionMenu') + expect(getComponents('Show plain status text')).toEqual(['Text']) + expect(getComponents('Add tabs for navigation')).not.toContain('NavList') + expect(getComponents('Add pagination to this list')).toEqual(expect.arrayContaining(['Pagination'])) + expect(getComponents('Add pagination to this list')).not.toEqual(expect.arrayContaining(['ActionList', 'NavList'])) + expect(getComponents('Select a repository')).toEqual(expect.arrayContaining(['Select'])) + expect(getComponents('Select a repository')).not.toContain('ActionList') + expect(getComponents('Show a user account menu')).not.toContain('Avatar') + expect(getComponents('Show a selected record detail')).not.toContain('Details') + expect(getComponents('Show sidebar navigation beside selected record detail')).toEqual( + expect.arrayContaining(['PageLayout', 'NavList']), + ) + expect(getComponents('Show people in action rows with avatar images and status tags')).toEqual( + expect.arrayContaining(['ActionList', 'Avatar', 'Label']), + ) + }) + + it('does not treat partial tokens or weak pattern-only matches as useful recommendations', () => { + const emptyStates = [{id: 'empty-states', name: 'Empty States', category: 'ui' as const}] + const input = {intent: 'stat'} + const weakPatternInput = {intent: 'empty'} + const emptyStateDetails: CompactPatternDetails = { + ...searchDetails, + pattern: { + id: 'empty-states', + name: 'Empty States', + category: 'ui', + sourceUrl: 'https://primer.style/product/ui-patterns/empty-states', + }, + components: [ + { + id: 'internal-empty-state', + name: 'InternalEmptyState', + source: 'primer-internal', + sourceUrl: 'https://primer.style/product/internal-components/internal-empty-state', + }, + ], + } + + expect(rankPatterns(emptyStates, input)).toEqual([]) + expect(createRecommendation(input, rankPatterns(emptyStates, input), [], components)).toMatchObject({ + status: 'no-match', + patterns: [], + components: [], + }) + expect( + createRecommendation( + weakPatternInput, + rankPatterns(emptyStates, weakPatternInput), + [emptyStateDetails], + components, + ), + ).toMatchObject({status: 'no-match', patterns: [], components: []}) + }) + it('reports ambiguous and no-match intent with actionable states', () => { const ambiguous = {intent: 'search filter'} const noMatch = {intent: 'calendar'} @@ -148,12 +318,12 @@ describe('component recommendations', () => { expect( createRecommendation( ambiguous, - rankPatterns(listPatterns(), ambiguous), + rankPatterns(availablePatterns, ambiguous), [searchDetails, filterDetails], components, ).status, ).toBe('ambiguous') - expect(createRecommendation(noMatch, rankPatterns(listPatterns(), noMatch), [], components)).toMatchObject({ + expect(createRecommendation(noMatch, rankPatterns(availablePatterns, noMatch), [], components)).toMatchObject({ status: 'no-match', nextAction: expect.stringContaining('pattern hint'), }) @@ -161,7 +331,7 @@ describe('component recommendations', () => { it('excludes deprecated candidates and retains internal references as unresolved evidence', () => { const input = {intent: 'search'} - const result = createRecommendation(input, rankPatterns(listPatterns(), input), [searchDetails], components) + const result = createRecommendation(input, rankPatterns(availablePatterns, input), [searchDetails], components) expect(result.components.map(candidate => candidate.component.name)).not.toContain('Octicon') expect(result.exclusions).toContainEqual({name: 'Octicon', source: 'primer-public', reason: 'deprecated'}) @@ -174,7 +344,7 @@ describe('component recommendations', () => { const input = {intent: 'search', sourceScope: 'all' as const} const result = createRecommendation( input, - rankPatterns(listPatterns(), input), + rankPatterns(availablePatterns, input), [searchDetails], components, internalCatalog, @@ -195,17 +365,58 @@ describe('component recommendations', () => { expect(formatRecommendation(result)).toContain('GitHub-only; no public import') }) + it('keeps low-scoring internal-only patterns visible only for the all source scope', () => { + const internalOnlyPatterns = [{id: 'filter', name: 'Filter', category: 'scenario' as const}] + const internalOnlyDetails: CompactPatternDetails = { + ...filterDetails, + components: [ + { + id: 'filter', + name: 'Filter', + source: 'primer-internal', + sourceUrl: 'https://primer.style/product/internal-components/filter', + }, + ], + } + const input = {intent: 'filter'} + + const allScope = createRecommendation( + {...input, sourceScope: 'all'}, + rankPatterns(internalOnlyPatterns, input), + [internalOnlyDetails], + components, + internalCatalog, + ) + const publicScope = createRecommendation( + input, + rankPatterns(internalOnlyPatterns, input), + [internalOnlyDetails], + components, + internalCatalog, + ) + + expect(allScope).toMatchObject({ + status: 'partial-match', + patterns: [expect.objectContaining({pattern: expect.objectContaining({name: 'Filter'})})], + components: [], + internalComponents: [expect.objectContaining({component: expect.objectContaining({name: 'Filter'})})], + }) + expect(formatRecommendation(allScope)).toContain('Documented internal candidates') + expect(publicScope).toMatchObject({status: 'no-match', patterns: [], internalComponents: []}) + expect(formatRecommendation(publicScope)).toMatch(/^No Primer pattern matched/) + }) + it('uses stable lexical ordering and bounded payloads', () => { const input = {intent: 'search filter', limit: 1} const first = createRecommendation( input, - rankPatterns(listPatterns(), input), + rankPatterns(availablePatterns, input), [searchDetails, filterDetails], components, ) const second = createRecommendation( input, - rankPatterns(listPatterns(), input), + rankPatterns(availablePatterns, input), [searchDetails, filterDetails], components, ) diff --git a/packages/mcp/src/recommendations.ts b/packages/mcp/src/recommendations.ts index 5aa330bafc7..eef7d2a69a3 100644 --- a/packages/mcp/src/recommendations.ts +++ b/packages/mcp/src/recommendations.ts @@ -5,6 +5,7 @@ import {findInternalCatalogEntries, type InternalCatalogResult, type InternalCat const maximumEvidencePerCandidate = 3 const maximumAuxiliaryEntriesPerResult = 3 +const minimumPatternScoreWithoutPublicComponent = 20 const relationshipKeys = [ 'parent', 'child', @@ -36,9 +37,12 @@ export interface RecommendationComponent { status?: string sourceUrl: string searchTerms: Array + intentTerms?: Array composition?: RecommendationComposition } +export type RecommendationIntentTerm = string | {all: Array} + export interface RecommendationComposition { apiParentChild: Array apiSubcomponents: Array @@ -191,9 +195,12 @@ export function createRecommendation( const componentByName = new Map(components.map(component => [normalizeIdentifier(component.name), component])) const candidates = new Map() const internalCandidates = new Map() + const supportedPatternIds = new Set() const exclusions: Array = [] const unresolvedReferences: Array = [] + addIntentMetadataCandidates(candidates, components, input) + for (const detail of selectedDetails) { const pattern = selectedPatterns.find(candidate => candidate.pattern.id === detail.pattern.id) if (!pattern) continue @@ -213,6 +220,7 @@ export function createRecommendation( detail: `${detail.pattern.name} links to documented internal ${entry.name}.`, }, }) + supportedPatternIds.add(pattern.pattern.id) continue } @@ -265,6 +273,7 @@ export function createRecommendation( detail: `${detail.pattern.name} links to ${component.name}.`, }, }) + supportedPatternIds.add(pattern.pattern.id) } } @@ -295,19 +304,8 @@ export function createRecommendation( } } - expandComposition(candidates, componentByName, exclusions) + expandComposition(candidates, componentByName, exclusions, input) - const patternCandidates = selectedPatterns.map(candidate => ({ - pattern: { - id: candidate.pattern.id, - name: candidate.pattern.name, - category: candidate.pattern.category, - sourceUrl: getPatternUrl(candidate.pattern).toString(), - }, - sourceKind: 'primer-style-pattern' as const, - score: candidate.score, - scoreBreakdown: candidate.scoreBreakdown, - })) const componentCandidates = [...candidates.values()] .map(candidate => ({ ...candidate, @@ -322,11 +320,27 @@ export function createRecommendation( })) .sort(compareInternalComponentCandidates) .slice(0, limit) + const patternCandidates = selectedPatterns + .filter( + candidate => + candidate.score >= minimumPatternScoreWithoutPublicComponent || supportedPatternIds.has(candidate.pattern.id), + ) + .map(candidate => ({ + pattern: { + id: candidate.pattern.id, + name: candidate.pattern.name, + category: candidate.pattern.category, + sourceUrl: getPatternUrl(candidate.pattern).toString(), + }, + sourceKind: 'primer-style-pattern' as const, + score: candidate.score, + scoreBreakdown: candidate.scoreBreakdown, + })) const matchedSignals = getMatchedSignals(input, patternCandidates, componentCandidates) const unmetSignals = getUnmetSignals(input, matchedSignals) const ambiguous = patternCandidates.length > 1 && patternCandidates[0].score === patternCandidates[1].score const status = - patternCandidates.length === 0 + patternCandidates.length === 0 && componentCandidates.length === 0 && internalComponentCandidates.length === 0 ? 'no-match' : ambiguous ? 'ambiguous' @@ -405,6 +419,7 @@ function expandComposition( candidates: Map, componentByName: Map, exclusions: Array, + input: RecommendationInput, ) { const expandedRelationships = new Set() @@ -417,6 +432,12 @@ function expandComposition( for (const relatedName of getRelatedComponentNames(relationship, source.name)) { const related = componentByName.get(normalizeIdentifier(relatedName)) if (!related || related.name === source.name) continue + if ( + candidate.evidence.every(evidence => evidence.sourceKind === 'component-metadata') && + getIntentMetadataMatches(related, input).length === 0 + ) { + continue + } const relationshipKey = `${source.name}:${kind}:${related.name}` if (expandedRelationships.has(relationshipKey)) continue expandedRelationships.add(relationshipKey) @@ -445,6 +466,66 @@ function expandComposition( } } +function addIntentMetadataCandidates( + candidates: Map, + components: Array, + input: RecommendationInput, +) { + for (const component of components) { + if (!isCompatible(component)) continue + + const matches = getIntentMetadataMatches(component, input) + if (matches.length === 0) continue + + addCandidate(candidates, component, { + score: matches.length * 12, + scoreBreakdown: [{source: 'component-metadata', score: matches.length * 12, matches}], + evidence: { + sourceKind: 'component-metadata', + sourceUrl: component.sourceUrl, + detail: `${component.name} matches component intent metadata.`, + }, + }) + } +} + +function getIntentMetadataMatches(component: RecommendationComponent, input: RecommendationInput): Array { + const inputValues = [input.intent, input.surface, input.region, ...(input.states ?? []), ...(input.constraints ?? [])] + const inputTokens = tokens(inputValues) + const capabilityMatches = (component.intentTerms ?? []).flatMap(term => { + const requiredTerms = typeof term === 'string' ? [term] : term.all + const requiredTokens = tokens(requiredTerms) + + return [...requiredTokens].every(token => inputTokens.has(token)) + ? [typeof term === 'string' ? term : term.all.join(' + ')] + : [] + }) + + return [...new Set([...getFullNameMatches(component, inputValues), ...capabilityMatches])] +} + +function getFullNameMatches(component: RecommendationComponent, inputValues: Array): Array { + const inputPhrases = inputValues + .filter((value): value is string => Boolean(value)) + .map(normalizePhrase) + .filter(Boolean) + const inputWords = new Set( + inputValues + .filter((value): value is string => Boolean(value)) + .flatMap(value => value.toLowerCase().split(/[^a-z0-9]+/)) + .filter(Boolean), + ) + + return [...new Set([component.id, component.name])] + .filter(name => { + const phrase = normalizePhrase(name) + const compactName = normalizeIdentifier(name) + + return inputPhrases.some(inputPhrase => ` ${inputPhrase} `.includes(` ${phrase} `)) || inputWords.has(compactName) + }) + .map(normalizePhrase) +} + function getRelationships(composition: RecommendationComposition): Array<[string, Array]> { return [ ['api-parent-child', composition.apiParentChild], @@ -598,7 +679,7 @@ function tokens(values: string | Array): Set { function stem(token: string): string { if (token.endsWith('ing') && token.length > 5) return token.slice(0, -3) - if (token.endsWith('es') && token.length > 4) return token.slice(0, -2) + if (token.endsWith('ies') && token.length > 4) return `${token.slice(0, -3)}y` if (token.endsWith('s') && token.length > 3) return token.slice(0, -1) return token } @@ -611,6 +692,14 @@ function normalizeIdentifier(value: string): string { return value.replace(/[^a-z0-9]/gi, '').toLowerCase() } +function normalizePhrase(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + function isCompatible(component: RecommendationComponent): boolean { return component.importPath === '@primer/react' && component.status !== 'deprecated' } diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index e7b73ab6353..2faf3a2531c 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -2,7 +2,9 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js' import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js' import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest' import {getComponentDocsSource} from './primer' -import {server} from './server' +import {clearCssLintResultCacheForTesting, clearPatternDetailsCacheForTesting, server} from './server' +// eslint-disable-next-line import/no-namespace +import * as primitives from './primitives' const searchPatternPage = `
@@ -37,6 +39,14 @@ const internalCatalogPage = JSON.stringify({ ], }) +function createPatternPage(patternName: string, componentName: string, componentId: string): string { + return searchPatternPage + .replace('

Search

', `

${patternName}

`) + .replace('Search finds things that match specific criteria.', `${patternName} guidance.`) + .replace('/product/components/text-input/', `/product/components/${componentId}/`) + .replace('>TextInput<', `>${componentName}<`) +} + describe('component documentation sources', () => { it('uses hosted documentation by default and supports package metadata', () => { expect(getComponentDocsSource(undefined)).toBe('hosted') @@ -57,11 +67,15 @@ describe('get_component_batch', () => { }) beforeEach(() => { + clearCssLintResultCacheForTesting() + clearPatternDetailsCacheForTesting() + vi.spyOn(primitives, 'runStylelint') vi.stubGlobal('fetch', vi.fn()) }) afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -384,6 +398,117 @@ describe('get_component_batch', () => { expect(getTextContent(result)).toContain('Public components') }) + it.each([ + ['search', 'Search', 'TextInput', 'text-input', true], + ['forms', 'Forms', 'Button', 'button', true], + ['navigation', 'Navigation', 'NavList', 'nav-list', true], + ['saving', 'Saving', 'Banner', 'banner', true], + ['empty states', 'Empty States', 'Blankslate', 'blankslate', false], + ])( + 'preserves %s recommendation candidates and content across a warm pattern cache', + async (intent, patternName, componentName, componentId, expectsComponent = true) => { + vi.mocked(fetch).mockResolvedValue(new Response(createPatternPage(patternName, componentName, componentId))) + + const cold = await callRecommendation({intent}) + const warm = await callRecommendation({intent}) + const coldPayload = cold.structuredContent as { + patterns: Array<{pattern: {name: string}}> + components: Array<{component: {name: string}}> + } + + expect(cold.structuredContent).toEqual(warm.structuredContent) + expect(getTextContent(cold)).toBe(getTextContent(warm)) + expect(coldPayload.patterns.map(candidate => candidate.pattern.name)).toContain(patternName) + if (expectsComponent) { + expect(coldPayload.components.map(candidate => candidate.component.name)).toContain(componentName) + } else { + expect(coldPayload.components).toEqual([]) + } + expect(fetch).toHaveBeenCalledOnce() + }, + ) + + it('shares concurrent pattern enrichment and only fetches selected ranked patterns', async () => { + let resolveSearch: ((response: Response) => void) | undefined + let resolveFilter: ((response: Response) => void) | undefined + vi.mocked(fetch).mockImplementation(input => { + const path = new URL(input.toString()).pathname + return new Promise(resolve => { + if (path.includes('/search')) resolveSearch = resolve + else resolveFilter = resolve + }) + }) + + const first = callRecommendation({intent: 'search filter'}) + const second = callRecommendation({intent: 'search filter'}) + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + resolveSearch?.(new Response(searchPatternPage)) + resolveFilter?.(new Response(searchPatternPage.replace('

Search

', '

Filter

'))) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(fetch).toHaveBeenCalledTimes(2) + + clearPatternDetailsCacheForTesting() + vi.mocked(fetch).mockClear() + vi.mocked(fetch).mockResolvedValue(new Response(searchPatternPage)) + await callRecommendation({intent: 'search filter', limit: 1}) + expect(fetch).toHaveBeenCalledOnce() + }) + + it('does not cache failed pattern enrichment responses', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, {status: 503, statusText: 'Service Unavailable'})) + + const first = await callRecommendation({intent: 'search'}) + const second = await callRecommendation({intent: 'search'}) + + expect(first.isError).toBe(true) + expect(second.isError).toBe(true) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('runs CSS linting once for concurrent and unchanged final CSS', async () => { + vi.mocked(primitives.runStylelint).mockResolvedValue({stdout: '', stderr: ''}) + const css = '.Component { color: var(--fgColor-default); }' + + const [first, concurrent] = await Promise.all([callTool('lint_css', {css}), callTool('lint_css', {css})]) + const warm = await callTool('lint_css', {css}) + const changed = await callTool('lint_css', {css: `${css}\n.Component--selected { color: var(--fgColor-accent); }`}) + + expect(primitives.runStylelint).toHaveBeenCalledTimes(2) + expect(primitives.runStylelint).toHaveBeenNthCalledWith(1, css) + expect([getTextContent(first), getTextContent(concurrent)]).toEqual( + expect.arrayContaining([ + '✅ Stylelint passed (or was successfully autofixed).', + '✅ Stylelint passed; CSS unchanged.', + ]), + ) + expect(getTextContent(warm)).toBe('✅ Stylelint passed; CSS unchanged.') + expect(getTextContent(changed)).toBe('✅ Stylelint passed (or was successfully autofixed).') + }) + + it('does not retain failed CSS linting as a successful cached result', async () => { + vi.mocked(primitives.runStylelint).mockRejectedValue( + Object.assign(new Error('Stylelint exited with code 2'), {stdout: 'invalid CSS'}), + ) + const css = '.Component { color: ; }' + + const first = await callTool('lint_css', {css}) + const second = await callTool('lint_css', {css}) + + expect(primitives.runStylelint).toHaveBeenCalledTimes(2) + expect(getTextContent(first)).toContain('❌ Errors without autofix remaining:') + expect(getTextContent(second)).toContain('❌ Errors without autofix remaining:') + expect(getTextContent(second)).not.toContain('CSS unchanged') + }) + + it('preserves the single-icon missing result', async () => { + const result = await callTool('get_icon', {name: 'missing'}) + + expect(getTextContent(result)).toBe( + 'There is no icon named `missing` in the @primer/octicons-react package. For a full list of icons, use the `get_icon` tool.', + ) + }) + it('lists bounded documented internal components without presenting an import path', async () => { vi.mocked(fetch).mockResolvedValue(new Response(internalCatalogPage)) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 13ef274192f..01723c6b271 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -9,12 +9,20 @@ import { getComponentCompositionSummary, getComponentDocsSource, getComponentDocument, + getComponentRecommendationTerms, getComponentSummary, listComponents, listPatterns, listIcons, + type Pattern, } from './primer' -import {formatCompactPatternDetails, getPatternUrl, parseCompactPatternDetails, toFullPatternDetails} from './patterns' +import { + formatCompactPatternDetails, + getPatternUrl, + parseCompactPatternDetails, + toFullPatternDetails, + type CompactPatternDetails, +} from './patterns' import {createRecommendation, formatRecommendation, rankPatterns, type RecommendationComponent} from './recommendations' import {fetchInternalCatalog} from './internalCatalog' import { @@ -39,6 +47,10 @@ const server = new McpServer({ version: packageJson.version, }) +const maximumCachedPatternDetails = 24 +const compactPatternDetailsCache = new Map>() +const maximumCachedCssLintResults = 24 +const cssLintResultCache = new Map>() const turndownService = new TurndownService() const defaultComponentDocsSource = getComponentDocsSource() const patternReferenceOutputSchema = z.object({ @@ -74,6 +86,11 @@ const recommendationOutputSchema = z }) .passthrough() +interface CssLintResult { + passed: boolean + text: string +} + // Load all tokens with guidelines from primitives const allTokensWithGuidelines: TokenWithGuidelines[] = loadAllTokensWithGuidelines() @@ -87,6 +104,95 @@ async function fetchTextOnlyContent(url: URL): Promise { throw new Error(`Failed to fetch ${llmsUrl}: ${response.statusText}`) } +function fetchCompactPatternDetails(pattern: Pattern): Promise { + const url = getPatternUrl(pattern) + const cacheKey = url.toString() + const cached = compactPatternDetailsCache.get(cacheKey) + if (cached) { + compactPatternDetailsCache.delete(cacheKey) + compactPatternDetailsCache.set(cacheKey, cached) + return cached + } + + const pending = (async () => { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch ${url} - ${response.statusText}`) + + const html = await response.text() + if (!html) throw new Error(`Primer Style returned an empty page for the \`${pattern.name}\` pattern.`) + + return parseCompactPatternDetails(html, pattern, url, listPatterns()) + })() + compactPatternDetailsCache.set(cacheKey, pending) + trimCompactPatternDetailsCache() + void removeFailedPatternDetails(cacheKey, pending) + + return pending +} + +async function removeFailedPatternDetails(cacheKey: string, pending: Promise) { + try { + await pending + } catch { + // Do not cache failures; the request that received it still propagates the error. + if (compactPatternDetailsCache.get(cacheKey) === pending) compactPatternDetailsCache.delete(cacheKey) + } +} + +function trimCompactPatternDetailsCache() { + while (compactPatternDetailsCache.size > maximumCachedPatternDetails) { + const oldestCacheKey = compactPatternDetailsCache.keys().next().value + if (oldestCacheKey === undefined) return + compactPatternDetailsCache.delete(oldestCacheKey) + } +} + +export function clearPatternDetailsCacheForTesting() { + compactPatternDetailsCache.clear() +} + +function lintCss(css: string): {result: Promise; cached: boolean} { + const cached = cssLintResultCache.get(css) + if (cached) { + cssLintResultCache.delete(css) + cssLintResultCache.set(css, cached) + return {result: cached, cached: true} + } + + const result = (async (): Promise => { + try { + const {stdout} = await runStylelint(css) + return {passed: true, text: stdout || '✅ Stylelint passed (or was successfully autofixed).'} + } catch (error: unknown) { + const errorOutput = + error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error) + return {passed: false, text: `❌ Errors without autofix remaining:\n${errorOutput}`} + } + })() + cssLintResultCache.set(css, result) + trimCssLintResultCache() + void removeFailedCssLintResult(css, result) + + return {result, cached: false} +} + +async function removeFailedCssLintResult(css: string, result: Promise) { + const completed = await result + if (!completed.passed && cssLintResultCache.get(css) === result) cssLintResultCache.delete(css) +} + +function trimCssLintResultCache() { + while (cssLintResultCache.size > maximumCachedCssLintResults) { + const oldestCss = cssLintResultCache.keys().next().value + if (oldestCss === undefined) return + cssLintResultCache.delete(oldestCss) + } +} + +export function clearCssLintResultCacheForTesting() { + cssLintResultCache.clear() +} + function convertMainHtmlToMarkdown(html: string): string | undefined { if (!html) return undefined @@ -783,16 +889,7 @@ server.registerTool( async input => { const rankedPatterns = rankPatterns(listPatterns(), input) const details = await Promise.all( - rankedPatterns.slice(0, input.limit * 2).map(async candidate => { - const url = getPatternUrl(candidate.pattern) - const response = await fetch(url) - if (!response.ok) throw new Error(`Failed to fetch ${url} - ${response.statusText}`) - - const html = await response.text() - if (!html) throw new Error(`Primer Style returned an empty page for the \`${candidate.pattern.name}\` pattern.`) - - return parseCompactPatternDetails(html, candidate.pattern, url, listPatterns()) - }), + rankedPatterns.slice(0, input.limit).map(candidate => fetchCompactPatternDetails(candidate.pattern)), ) const components: Array = listComponents().map(component => { const document = getComponentDocument(component.id) @@ -805,6 +902,7 @@ server.registerTool( status, sourceUrl: new URL(`/product/components/${component.slug}`, 'https://primer.style').toString(), searchTerms, + intentTerms: getComponentRecommendationTerms(component.id), composition, } }) @@ -826,7 +924,7 @@ server.registerTool( 'find_tokens', { description: - 'Search for specific tokens. Tip: If you only provide a \'group\' and leave \'query\' empty, it returns all tokens in that category. Avoid property-by-property searching. COLOR RESOLUTION: If a user asks for "pink" or "blue", do not search for the color name. Use the semantic intent: blue->accent, red->danger, green->success. Always check both "emphasis" and "muted" variants for background colors. After identifying tokens and writing CSS, you MUST validate the result using lint_css.', + 'Search for specific tokens. Tip: If you only provide a \'group\' and leave \'query\' empty, it returns all tokens in that category. Avoid property-by-property searching. COLOR RESOLUTION: If a user asks for "pink" or "blue", do not search for the color name. Use the semantic intent: blue->accent, red->danger, green->success. Always check both "emphasis" and "muted" variants for background colors. After finalizing combined CSS, validate it once with lint_css and rerun only when the CSS bytes change.', inputSchema: { query: z .string() @@ -1035,36 +1133,16 @@ server.registerTool( 'lint_css', { description: - 'REQUIRED FINAL STEP. Use this to validate your CSS. You cannot complete a task involving CSS without a successful run of this tool.', + 'REQUIRED ONCE AFTER FINAL COMBINED CSS. Run this after all CSS edits are complete; rerun only when the CSS bytes change. You cannot complete a task involving CSS without a successful run.', inputSchema: {css: z.string()}, annotations: {readOnlyHint: true}, }, async ({css}) => { - try { - // --fix flag tells Stylelint to repair what it can - const {stdout} = await runStylelint(css) + const {result, cached} = lintCss(css) + const completed = await result + const text = cached && completed.passed ? '✅ Stylelint passed; CSS unchanged.' : completed.text - return { - content: [ - { - type: 'text', - text: stdout || '✅ Stylelint passed (or was successfully autofixed).', - }, - ], - } - } catch (error: unknown) { - // If Stylelint still has errors it CANNOT fix, it will land here - const errorOutput = - error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error) - return { - content: [ - { - type: 'text', - text: `❌ Errors without autofix remaining:\n${errorOutput}`, - }, - ], - } - } + return {content: [{type: 'text', text}]} }, ) @@ -1174,44 +1252,30 @@ server.registerTool( annotations: {readOnlyHint: true}, }, async ({name, size}) => { - const icons = listIcons() - const match = icons.find(icon => { - return icon.name === name || icon.name.toLowerCase() === name.toLowerCase() - }) - if (!match) { - return { - content: [ - { - type: 'text', - text: `There is no icon named \`${name}\` in the @primer/octicons-react package. For a full list of icons, use the \`get_icon\` tool.`, - }, - ], - } - } + const text = await getIconDocumentation(name, size) + return {content: text ? [{type: 'text', text}] : []} + }, +) - const url = new URL(`/octicons/icon/${match.name}-${size}`, 'https://primer.style') - let text = await fetchTextOnlyContent(url) - if (text === undefined) { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.statusText}`) - } +async function getIconDocumentation(name: string, size: string): Promise { + const match = listIcons().find(icon => icon.name === name || icon.name.toLowerCase() === name.toLowerCase()) + if (!match) { + return `There is no icon named \`${name}\` in the @primer/octicons-react package. For a full list of icons, use the \`get_icon\` tool.` + } - text = convertMainHtmlToMarkdown(await response.text()) - } - if (!text) return {content: []} + const url = new URL(`/octicons/icon/${match.name}-${size}`, 'https://primer.style') + let text = await fetchTextOnlyContent(url) + if (text === undefined) { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`) - return { - content: [ - { - type: 'text', - text: `Here is the documentation for the \`${name}\` icon at size: \`${size}\`: -${text}`, - }, - ], - } - }, -) + text = convertMainHtmlToMarkdown(await response.text()) + } + if (!text) return undefined + + return `Here is the documentation for the \`${name}\` icon at size: \`${size}\`: +${text}` +} // ----------------------------------------------------------------------------- // Coding guidelines