Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/compact-mcp-pattern-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/mcp': minor
---

MCP: Return compact structured pattern guidance with explicit component references and an opt-in full-detail mode.
17 changes: 17 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
86 changes: 86 additions & 0 deletions packages/mcp/src/patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {describe, expect, it} from 'vitest'
import {formatCompactPatternDetails, getPatternUrl, parseCompactPatternDetails} from './patterns'
import {listPatterns} from './primer'

const searchPatternPage = `
<main>
<div>
<div><nav>Product UI / Scenario patterns</nav><h1>Search</h1></div>
<div>
<p><img alt="" src="/assets/search-anatomy.png" /></p>
<p>Search finds things that match specific criteria without including navigation chrome.</p>
<h2>Implementation guidelines</h2>
<p>Use <a href="/product/components/text-input/">TextInput</a> for a simple query.</p>
<p>Use the <a href="/product/internal-components/filter/">Filter</a> component for tokenised qualifiers.</p>
<h3>Loading results</h3>
<p>Set aria-busy="true" while loading, and show an empty state when no results match.</p>
<h2>Related scenario patterns</h2>
<ul><li><a href="/product/scenario-patterns/filter/">Filter</a></li></ul>
</div>
</div>
</main>
`

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('<main><h1>Search</h1></main>', search, getPatternUrl(search), patterns),
).toThrow('Unable to locate the pattern guidance content in the Primer Style page.')
})
})
272 changes: 272 additions & 0 deletions packages/mcp/src/patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// eslint-disable-next-line import/no-namespace
import * as cheerio from 'cheerio'
import type {Pattern} from './primer'

type CheerioContent = ReturnType<cheerio.CheerioAPI>

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<string>
states: Array<string>
}

export interface CompactPatternDetails extends Record<string, unknown> {
detail: 'compact'
pattern: PatternReference
summary: string
components: Array<PatternComponentReference>
relatedPatterns: Array<PatternReference>
guidance: PatternGuidance
}

export interface FullPatternDetails extends Record<string, unknown> {
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<Pattern>,
): 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<PatternComponentReference> {
const references = new Map<string, PatternComponentReference>()

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<Pattern>,
): Array<PatternReference> {
const heading = content
.find('h2')
.filter((_, element) => /related .*patterns/i.test(getText($(element))))
.first()
if (!heading.length) return []

const references = new Map<string, PatternReference>()
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<string> {
const guidance = new Set<string>()

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<string>()

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<Pattern>,
): 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()}…`
}
2 changes: 1 addition & 1 deletion packages/mcp/src/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Loading
Loading