Skip to content

Commit 1fc01cd

Browse files
committed
Add MCP component recommendations
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32388d20-7134-40a1-af5d-8325d6bea553
1 parent 285d8a9 commit 1fc01cd

6 files changed

Lines changed: 900 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/mcp': minor
3+
---
4+
5+
MCP: Add deterministic Primer pattern and public component recommendations for product UI intent.

packages/mcp/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ conversion. The compact parser intentionally fails when it cannot identify the
7979
page's guidance body, rather than returning navigation content as pattern
8080
guidance.
8181

82+
## Recommendations
83+
84+
`recommend_components` maps freeform product or UI intent to a bounded set of
85+
Primer patterns and public `@primer/react` component candidates. Optional
86+
surface, region, pattern-hint, state, constraint, existing-component, and
87+
preferred-component signals refine deterministic lexical ranking. Results
88+
include the source URLs and kinds for pattern links and package-derived
89+
composition evidence. Deprecated, incompatible, and Primer-internal references
90+
are excluded from installable component candidates; unresolved internal links
91+
remain source-labeled evidence.
92+
8293
## 🙌 Contributing
8394

8495
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.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {describe, expect, it} from 'vitest'
2+
import type {CompactPatternDetails} from './patterns'
3+
import {createRecommendation, formatRecommendation, rankPatterns, type RecommendationComponent} from './recommendations'
4+
import {listPatterns} from './primer'
5+
6+
const sourceUrl = 'https://primer.style/product/components/text-input'
7+
const searchDetails: CompactPatternDetails = {
8+
detail: 'compact',
9+
pattern: {
10+
id: 'search',
11+
name: 'Search',
12+
category: 'scenario',
13+
sourceUrl: 'https://primer.style/product/scenario-patterns/search',
14+
},
15+
summary: 'Search guidance.',
16+
components: [
17+
{id: 'text-input', name: 'TextInput', source: 'primer-public', sourceUrl},
18+
{
19+
id: 'filter',
20+
name: 'Filter',
21+
source: 'primer-internal',
22+
sourceUrl: 'https://primer.style/product/internal-components/filter',
23+
},
24+
{
25+
id: 'octicon',
26+
name: 'Octicon',
27+
source: 'primer-public',
28+
sourceUrl: 'https://primer.style/product/components/octicon',
29+
},
30+
],
31+
relatedPatterns: [],
32+
guidance: {implementation: '', accessibility: [], states: []},
33+
}
34+
const filterDetails: CompactPatternDetails = {
35+
...searchDetails,
36+
pattern: {
37+
id: 'filter',
38+
name: 'Filter',
39+
category: 'scenario',
40+
sourceUrl: 'https://primer.style/product/scenario-patterns/filter',
41+
},
42+
components: [
43+
{
44+
id: 'action-menu',
45+
name: 'ActionMenu',
46+
source: 'primer-public',
47+
sourceUrl: 'https://primer.style/product/components/action-menu',
48+
},
49+
],
50+
}
51+
const components: Array<RecommendationComponent> = [
52+
{
53+
id: 'text_input',
54+
name: 'TextInput',
55+
importPath: '@primer/react',
56+
sourceUrl,
57+
searchTerms: ['TextInput', 'query', 'search'],
58+
},
59+
{
60+
id: 'action_menu',
61+
name: 'ActionMenu',
62+
importPath: '@primer/react',
63+
sourceUrl: 'https://primer.style/product/components/action-menu',
64+
searchTerms: ['ActionMenu', 'filter'],
65+
composition: {
66+
apiParentChild: [],
67+
apiSubcomponents: [],
68+
observed: {
69+
parentChild: [{parent: 'ActionMenu.Overlay', child: 'ActionList', sourceCount: 2}],
70+
adjacentSibling: [],
71+
variants: [],
72+
relatedComponents: [],
73+
},
74+
},
75+
},
76+
{
77+
id: 'action_list',
78+
name: 'ActionList',
79+
importPath: '@primer/react',
80+
sourceUrl: 'https://primer.style/product/components/action-list',
81+
searchTerms: ['ActionList'],
82+
},
83+
{
84+
id: 'octicon',
85+
name: 'Octicon',
86+
importPath: '@primer/react/deprecated',
87+
status: 'deprecated',
88+
sourceUrl: 'https://primer.style/product/components/octicon',
89+
searchTerms: ['Octicon'],
90+
},
91+
]
92+
93+
describe('component recommendations', () => {
94+
it('matches simple intent to authoritative pattern-linked public components', () => {
95+
const input = {intent: 'Add a search query'}
96+
const result = createRecommendation(input, rankPatterns(listPatterns(), input), [searchDetails], components)
97+
98+
expect(result.status).toBe('matched')
99+
expect(result.patterns[0]?.pattern.name).toBe('Search')
100+
expect(result.components[0]?.component).toMatchObject({name: 'TextInput', sourceKind: 'primer-public'})
101+
expect(formatRecommendation(result)).toContain('Public components')
102+
})
103+
104+
it('lets structured pattern hints outweigh freeform intent', () => {
105+
const input = {intent: 'search', patternHints: ['filter']}
106+
const result = createRecommendation(
107+
input,
108+
rankPatterns(listPatterns(), input),
109+
[searchDetails, filterDetails],
110+
components,
111+
)
112+
113+
expect(result.patterns[0]?.pattern.name).toBe('Filter')
114+
expect(result.matchedSignals.patternHints).toEqual(['filter'])
115+
})
116+
117+
it('expands source-derived composition relationships without inventing mappings', () => {
118+
const input = {intent: 'filter'}
119+
const result = createRecommendation(input, rankPatterns(listPatterns(), input), [filterDetails], components)
120+
121+
expect(result.components.map(candidate => candidate.component.name)).toEqual(['ActionMenu', 'ActionList'])
122+
expect(result.components[1]?.evidence[0]).toMatchObject({sourceKind: 'composition'})
123+
})
124+
125+
it('reports ambiguous and no-match intent with actionable states', () => {
126+
const ambiguous = {intent: 'search filter'}
127+
const noMatch = {intent: 'calendar'}
128+
129+
expect(
130+
createRecommendation(
131+
ambiguous,
132+
rankPatterns(listPatterns(), ambiguous),
133+
[searchDetails, filterDetails],
134+
components,
135+
).status,
136+
).toBe('ambiguous')
137+
expect(createRecommendation(noMatch, rankPatterns(listPatterns(), noMatch), [], components)).toMatchObject({
138+
status: 'no-match',
139+
nextAction: expect.stringContaining('pattern hint'),
140+
})
141+
})
142+
143+
it('excludes deprecated candidates and retains internal references as unresolved evidence', () => {
144+
const input = {intent: 'search'}
145+
const result = createRecommendation(input, rankPatterns(listPatterns(), input), [searchDetails], components)
146+
147+
expect(result.components.map(candidate => candidate.component.name)).not.toContain('Octicon')
148+
expect(result.exclusions).toContainEqual({name: 'Octicon', source: 'primer-public', reason: 'deprecated'})
149+
expect(result.unresolvedReferences).toContainEqual(
150+
expect.objectContaining({name: 'Filter', source: 'primer-internal', reason: 'internal-reference'}),
151+
)
152+
})
153+
154+
it('uses stable lexical ordering and bounded payloads', () => {
155+
const input = {intent: 'search filter', limit: 1}
156+
const first = createRecommendation(
157+
input,
158+
rankPatterns(listPatterns(), input),
159+
[searchDetails, filterDetails],
160+
components,
161+
)
162+
const second = createRecommendation(
163+
input,
164+
rankPatterns(listPatterns(), input),
165+
[searchDetails, filterDetails],
166+
components,
167+
)
168+
169+
expect(first).toEqual(second)
170+
expect(first.patterns).toHaveLength(1)
171+
expect(first.components).toHaveLength(1)
172+
expect(JSON.stringify(first).length).toBeLessThan(5_000)
173+
})
174+
})

0 commit comments

Comments
 (0)