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/component-composition-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Generated component metadata: Add source-derived composition relationship evidence.
5 changes: 5 additions & 0 deletions .changeset/mcp-component-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/mcp': minor
---

MCP: Add package-backed component composition metadata and documentation source selection.
18 changes: 18 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ Your MCP servers configuration should look like:
}
```

## Component metadata sources

`get_component_composition` returns source-derived composition metadata from the
installed `@primer/react` package. It is package-backed, so a local workspace
can exercise newly generated metadata without waiting for hosted Primer Style
documentation to deploy.

`get_component` and `get_component_batch` use hosted Primer Style documentation
by default. Pass `source: "package"` to return the installed package's
generated component metadata and composition instead. Set
`PRIMER_COMPONENT_DOCS_SOURCE=package` when starting the server to make package
metadata the default; use `PRIMER_COMPONENT_DOCS_SOURCE=hosted` to retain the
hosted default. The per-call `source` input takes precedence over the startup
default. Package-mode batches return a compact composition-first summary;
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.

## 🙌 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.
180 changes: 178 additions & 2 deletions packages/mcp/src/primer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,59 @@
import componentsMetadata from '@primer/react/generated/components.json' with {type: 'json'}
import octicons from '@primer/octicons/build/data.json' with {type: 'json'}

type ComponentDocsSource = 'hosted' | 'package'

type Component = {
id: string
name: string
importPath: string
slug: string
}

type ComponentSummary = Pick<Component, 'id' | 'name' | 'importPath'> & {
status?: string
}

interface ComponentDocument {
name: string
importPath: string
[key: string]: unknown
}

interface ComponentRelationship {
parent?: string
child?: string
previous?: string
next?: string
first?: string
second?: string
component?: string
subcomponent?: string
[key: string]: unknown
}

interface CompositionMetadata {
schemaVersion: number
derivation: Record<string, unknown>
sourceSummary: Record<string, unknown>
apiParentChild: Array<ComponentRelationship>
apiSubcomponents: Array<ComponentRelationship>
observed: {
parentChild: Array<ComponentRelationship>
adjacentSibling: Array<ComponentRelationship>
variants: Array<ComponentRelationship>
relatedComponents: Array<ComponentRelationship>
}
}

interface ComponentsMetadata {
components: Record<string, ComponentDocument>
composition?: CompositionMetadata
}

const metadata: ComponentsMetadata = componentsMetadata
const maximumObservedRelationshipsPerKind = 3

function idToSlug(id: string): string {
if (id === 'actionbar') {
return 'action-bar'
Expand All @@ -24,7 +70,7 @@ function idToSlug(id: string): string {
return id.replaceAll('_', '-')
}

const components: Array<Component> = Object.entries(componentsMetadata.components).map(([id, component]) => {
const components: Array<Component> = Object.entries(metadata.components).map(([id, component]) => {
return {
id,
name: component.name,
Expand All @@ -37,6 +83,126 @@ function listComponents(): Array<Component> {
return components
}

function getComponentDocsSource(value = process.env.PRIMER_COMPONENT_DOCS_SOURCE): ComponentDocsSource {
if (value === undefined || value === 'hosted' || value === 'package') {
return value ?? 'hosted'
}

throw new Error('PRIMER_COMPONENT_DOCS_SOURCE must be either "hosted" or "package".')
}

function getComponentDocument(id: string): ComponentDocument | undefined {
return metadata.components[id]
}

function getComponentSummary(component: Component): ComponentSummary {
const status = getComponentDocument(component.id)?.status

return typeof status === 'string'
? {id: component.id, name: component.name, importPath: component.importPath, status}
: {id: component.id, name: component.name, importPath: component.importPath}
}

function getComponentComposition(id: string) {
const component = getComponentDocument(id)
const composition = metadata.composition
if (!component || !composition) return undefined

return {
schemaVersion: composition.schemaVersion,
derivation: composition.derivation,
sourceSummary: composition.sourceSummary,
apiParentChild: composition.apiParentChild.filter(relation => includesComponent(relation, component.name)),
apiSubcomponents: composition.apiSubcomponents.filter(relation => includesComponent(relation, component.name)),
observed: {
parentChild: composition.observed.parentChild.filter(relation => includesComponent(relation, component.name)),
adjacentSibling: composition.observed.adjacentSibling.filter(relation =>
includesComponent(relation, component.name),
),
variants: composition.observed.variants.filter(relation => includesComponent(relation, component.name)),
relatedComponents: composition.observed.relatedComponents.filter(relation =>
includesComponent(relation, component.name),
),
},
}
}

function getComponentCompositionSummary(id: string) {
const composition = getComponentComposition(id)
if (!composition) return undefined

const parentChild = summarizeObservedRelationships(composition.observed.parentChild)
const adjacentSibling = summarizeObservedRelationships(composition.observed.adjacentSibling)
const variants = summarizeObservedRelationships(composition.observed.variants)
const relatedComponents = summarizeObservedRelationships(composition.observed.relatedComponents)

return {
schemaVersion: composition.schemaVersion,
apiParentChild: composition.apiParentChild.map(compactRelationship),
apiSubcomponents: composition.apiSubcomponents.map(compactRelationship),
observed: {
parentChild: parentChild.relationships,
adjacentSibling: adjacentSibling.relationships,
variants: variants.relationships,
relatedComponents: relatedComponents.relationships,
},
observedRelationshipLimit: maximumObservedRelationshipsPerKind,
omittedObservedRelationshipCounts: {
parentChild: parentChild.omittedCount,
adjacentSibling: adjacentSibling.omittedCount,
variants: variants.omittedCount,
relatedComponents: relatedComponents.omittedCount,
},
}
}

function summarizeObservedRelationships(relationships: Array<ComponentRelationship>) {
const orderedRelationships = [...relationships].sort((first, second) => {
return (
getRelationshipNumber(second, 'sourceCount') - getRelationshipNumber(first, 'sourceCount') ||
getRelationshipNumber(second, 'occurrences') - getRelationshipNumber(first, 'occurrences') ||
JSON.stringify(compactRelationship(first)).localeCompare(JSON.stringify(compactRelationship(second)))
)
})

return {
relationships: orderedRelationships.slice(0, maximumObservedRelationshipsPerKind).map(compactRelationship),
omittedCount: Math.max(orderedRelationships.length - maximumObservedRelationshipsPerKind, 0),
}
}

function getRelationshipNumber(relationship: ComponentRelationship, key: string): number {
const value = relationship[key]
return typeof value === 'number' ? value : 0
}

function compactRelationship(relationship: ComponentRelationship): ComponentRelationship {
const compact: ComponentRelationship = {}
for (const [key, value] of Object.entries(relationship)) {
if (key !== 'sources') compact[key] = value
}

return compact
}

function includesComponent(relation: ComponentRelationship, componentName: string): boolean {
const componentFields = [
'parent',
'child',
'previous',
'next',
'first',
'second',
'component',
'subcomponent',
] as const

return componentFields.some(field => {
const value = relation[field]
return value === componentName || value?.startsWith(`${componentName}.`)
})
}

type Pattern = {
id: string
name: string
Expand Down Expand Up @@ -140,4 +306,14 @@ function listIcons(): Array<Icon> {
return icons
}

export {listComponents, listPatterns, listIcons}
export {
getComponentComposition,
getComponentCompositionSummary,
getComponentDocsSource,
getComponentDocument,
getComponentSummary,
listComponents,
listPatterns,
listIcons,
type ComponentDocsSource,
}
Loading
Loading