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/mcp-internal-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/mcp': minor
---

MCP: Add documented Primer-internal catalog discovery and opt-in GitHub-only recommendation candidates.
23 changes: 19 additions & 4 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,28 @@ guidance.
## Recommendations

`recommend_components` maps freeform product or UI intent to a bounded set of
Primer patterns and public `@primer/react` component candidates. Optional
Primer patterns and public `@primer/react` component candidates. It defaults to
`sourceScope: "public"` so external consumers receive only installable public
candidates. Set `sourceScope: "all"` for a separate, bounded
`internalComponents` collection of documented GitHub-only candidates. Internal
candidates are explicitly non-installable, have no public import, and are never
mixed into the `components` collection or returned from `get_component`.

`list_internal_components` retrieves the documented internal catalog directly
from Primer Style. It exposes only safe documentation metadata: ids, names,
docs URLs, visibility, availability, and `implementationIncluded: false`. The
catalog is generated from the docs source and includes a build timestamp plus a
source-record `sourceRevision`; stale, missing, invalid, and unavailable
endpoints are returned explicitly without a fallback catalog.

Optional
surface, region, pattern-hint, state, constraint, existing-component, and
preferred-component signals refine deterministic lexical ranking. Results
include the source URLs and kinds for pattern links and package-derived
composition evidence. Deprecated, incompatible, and Primer-internal references
are excluded from installable component candidates; unresolved internal links
remain source-labeled evidence.
composition evidence. Deprecated and incompatible components are excluded from
installable candidates. Under the holistic scope, authored Primer-internal
pattern links resolve through the catalog; otherwise they remain source-labeled
unresolved evidence.

## 🙌 Contributing

Expand Down
91 changes: 91 additions & 0 deletions packages/mcp/src/internalCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {describe, expect, it, vi} from 'vitest'
import {
fetchInternalCatalog,
findInternalCatalogEntries,
getInternalCatalogUrl,
parseInternalCatalog,
} from './internalCatalog'

const catalog = {
schemaVersion: 1,
generatedAt: '2026-07-23T00:00:00.000Z',
sourceRevision: 'sha256:internal-catalog-v1',
entries: [
{
id: 'filter',
name: 'Filter',
sourceKind: 'primer-internal',
sourceUrl: 'https://primer.style/product/internal-components/filter',
visibility: 'github-only',
availability: 'unavailable',
implementationIncluded: false,
},
{
id: 'action-list-items',
name: 'Action list items',
sourceKind: 'primer-internal',
sourceUrl: 'https://primer.style/product/internal-components/action-list-items',
visibility: 'github-only',
availability: 'unavailable',
implementationIncluded: false,
},
],
} as const

describe('internal catalog', () => {
it('parses safe documented entries with deterministic order and reference lookup', () => {
const parsed = parseInternalCatalog(catalog)

expect(parsed?.entries.map(entry => entry.name)).toEqual(['Action list items', 'Filter'])
expect(
findInternalCatalogEntries(
parsed!,
[
{
id: 'filter',
name: 'Filter',
source: 'primer-internal',
sourceUrl: 'https://primer.style/product/internal-components/filter',
},
],
1,
),
).toEqual([expect.objectContaining({name: 'Filter', implementationIncluded: false})])
})

it('rejects private implementation metadata and non-Primer URLs', () => {
expect(
parseInternalCatalog({
...catalog,
entries: [
{
...catalog.entries[0],
sourceUrl: 'https://github.com/github/github-ui',
implementation: 'private source',
},
],
}),
).toBeUndefined()
})

it('reports unavailable endpoint responses without a fallback catalog', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, {status: 503}))

await expect(fetchInternalCatalog(fetcher)).resolves.toMatchObject({
status: 'unavailable',
message: expect.stringContaining('503'),
})
expect(fetcher).toHaveBeenCalledWith(getInternalCatalogUrl())
})

it('marks old generated catalogs as stale instead of silently trusting them', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({...catalog, generatedAt: '2026-01-01T00:00:00.000Z'})))

await expect(fetchInternalCatalog(fetcher, new Date('2026-07-23T00:00:00.000Z'))).resolves.toMatchObject({
status: 'stale',
catalog: {sourceRevision: 'sha256:internal-catalog-v1'},
})
})
})
171 changes: 171 additions & 0 deletions packages/mcp/src/internalCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type {ComponentSource, PatternComponentReference} from './patterns'

const catalogUrl = new URL('/product/internal-components/catalog.json', 'https://primer.style')
const maximumCatalogEntries = 100
const maximumCatalogAgeMilliseconds = 30 * 24 * 60 * 60 * 1000

export interface InternalCatalogEntry {
id: string
name: string
sourceKind: Extract<ComponentSource, 'primer-internal'>
sourceUrl: string
visibility: string
availability: string
implementationIncluded: false
}

export interface InternalCatalog {
schemaVersion: number
generatedAt: string
sourceRevision: string
entries: Array<InternalCatalogEntry>
}

export interface InternalCatalogResult {
status: 'available' | 'unavailable' | 'stale' | 'not-requested'
catalog?: InternalCatalog
message?: string
}

export async function fetchInternalCatalog(
fetcher: typeof fetch = fetch,
now: Date = new Date(),
): Promise<InternalCatalogResult> {
let response: Response
try {
response = await fetcher(catalogUrl)
} catch {
return {status: 'unavailable', message: 'The Primer internal catalog endpoint could not be reached.'}
}

if (!response.ok) {
return {
status: 'unavailable',
message: `The Primer internal catalog endpoint returned ${response.status}.`,
}
}

let value: unknown
try {
value = await response.json()
} catch {
return {status: 'unavailable', message: 'The Primer internal catalog endpoint returned invalid JSON.'}
}

const catalog = parseInternalCatalog(value)
if (!catalog) {
return {status: 'unavailable', message: 'The Primer internal catalog endpoint returned an invalid catalog.'}
}

if (isStale(catalog, now)) {
return {
status: 'stale',
catalog,
message: 'The Primer internal catalog is older than 30 days; review its source revision before relying on it.',
}
}

return {status: 'available', catalog}
}

export function parseInternalCatalog(value: unknown): InternalCatalog | undefined {
if (!isRecord(value)) return undefined
if (
typeof value.schemaVersion !== 'number' ||
!Number.isInteger(value.schemaVersion) ||
typeof value.generatedAt !== 'string' ||
typeof value.sourceRevision !== 'string' ||
!Array.isArray(value.entries)
) {
return undefined
}

const entries = value.entries.map(parseInternalCatalogEntry)
if (entries.some((entry): entry is undefined => entry === undefined)) return undefined

return {
schemaVersion: value.schemaVersion,
generatedAt: value.generatedAt,
sourceRevision: value.sourceRevision,
entries: entries
.filter((entry): entry is InternalCatalogEntry => entry !== undefined)
.sort((first, second) => first.name.localeCompare(second.name))
.slice(0, maximumCatalogEntries),
}
}

export function findInternalCatalogEntries(
catalog: InternalCatalog,
references: Array<PatternComponentReference>,
limit: number,
): Array<InternalCatalogEntry> {
const entryById = new Map(catalog.entries.map(entry => [normalize(entry.id), entry]))
const entryByName = new Map(catalog.entries.map(entry => [normalize(entry.name), entry]))

return references
.filter(reference => reference.source === 'primer-internal')
.flatMap(reference => {
const entry = entryById.get(normalize(reference.id)) ?? entryByName.get(normalize(reference.name))
return entry ? [entry] : []
})
.filter((entry, index, entries) => entries.findIndex(candidate => candidate.id === entry.id) === index)
.sort((first, second) => first.name.localeCompare(second.name))
.slice(0, limit)
}

export function getInternalCatalogUrl(): URL {
return new URL(catalogUrl)
}

function parseInternalCatalogEntry(value: unknown): InternalCatalogEntry | undefined {
if (!isRecord(value)) return undefined
if (
typeof value.id !== 'string' ||
typeof value.name !== 'string' ||
value.sourceKind !== 'primer-internal' ||
typeof value.sourceUrl !== 'string' ||
typeof value.visibility !== 'string' ||
typeof value.availability !== 'string' ||
value.implementationIncluded !== false ||
!isPrimerInternalUrl(value.sourceUrl)
) {
return undefined
}

return {
id: value.id,
name: value.name,
sourceKind: value.sourceKind,
sourceUrl: value.sourceUrl,
visibility: value.visibility,
availability: value.availability,
implementationIncluded: false,
}
}

function isStale(catalog: InternalCatalog, now: Date): boolean {
const generatedAt = Date.parse(catalog.generatedAt)
return (
catalog.schemaVersion !== 1 ||
Number.isNaN(generatedAt) ||
generatedAt > now.getTime() ||
now.getTime() - generatedAt > maximumCatalogAgeMilliseconds
)
}

function isPrimerInternalUrl(value: string): boolean {
try {
const url = new URL(value)
return url.origin === 'https://primer.style' && url.pathname.startsWith('/product/internal-components/')
} catch {
return false
}
}

function normalize(value: string): string {
return value.replace(/[^a-z0-9]/gi, '').toLowerCase()
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
2 changes: 1 addition & 1 deletion packages/mcp/src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const maximumSummaryLength = 480
const maximumGuidanceItemLength = 280
const maximumImplementationLength = 900

type ComponentSource = 'primer-public' | 'primer-internal'
export type ComponentSource = 'primer-public' | 'primer-internal'

export interface PatternReference {
id: string
Expand Down
44 changes: 44 additions & 0 deletions packages/mcp/src/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ const components: Array<RecommendationComponent> = [
searchTerms: ['Octicon'],
},
]
const internalCatalog = {
status: 'available' as const,
catalog: {
schemaVersion: 1,
generatedAt: '2026-07-23T00:00:00.000Z',
sourceRevision: 'sha256:internal-catalog-v1',
entries: [
{
id: 'filter',
name: 'Filter',
sourceKind: 'primer-internal' as const,
sourceUrl: 'https://primer.style/product/internal-components/filter',
visibility: 'github-only',
availability: 'unavailable',
implementationIncluded: false as const,
},
],
},
}

describe('component recommendations', () => {
it('matches simple intent to authoritative pattern-linked public components', () => {
Expand Down Expand Up @@ -151,6 +170,31 @@ describe('component recommendations', () => {
)
})

it('resolves documented internal references separately for the all source scope', () => {
const input = {intent: 'search', sourceScope: 'all' as const}
const result = createRecommendation(
input,
rankPatterns(listPatterns(), input),
[searchDetails],
components,
internalCatalog,
)

expect(result.components.map(candidate => candidate.component.name)).toEqual(['TextInput'])
expect(result.internalComponents).toEqual([
expect.objectContaining({
component: expect.objectContaining({
name: 'Filter',
sourceKind: 'primer-internal',
installable: false,
implementationIncluded: false,
}),
}),
])
expect(result.unresolvedReferences).not.toContainEqual(expect.objectContaining({name: 'Filter'}))
expect(formatRecommendation(result)).toContain('GitHub-only; no public import')
})

it('uses stable lexical ordering and bounded payloads', () => {
const input = {intent: 'search filter', limit: 1}
const first = createRecommendation(
Expand Down
Loading
Loading