Skip to content
Merged
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/tidy-tools-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/mcp': minor
---

Components: Add a batch tool for retrieving documentation for multiple components in one call.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"scripts": {
"clean": "rimraf dist",
"build": "rolldown -c",
"test": "vitest --run",
"type-check": "tsc --noEmit",
"watch": "rolldown -c -w"
},
Expand All @@ -43,6 +44,7 @@
},
"devDependencies": {
"@modelcontextprotocol/inspector": "^0.16.6",
"@primer/vitest-config": "^0.0.0",
"@types/turndown": "^5.0.5",
"rimraf": "^6.0.1",
"rolldown": "^1.1.2",
Expand Down
125 changes: 125 additions & 0 deletions packages/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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 {server} from './server'

describe('get_component_batch', () => {
const client = new Client({name: 'mcp-server-test', version: '1.0.0'})

beforeAll(async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
await client.connect(clientTransport)
})

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>())
})

afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})

afterAll(async () => {
await client.close()
await server.close()
})

const callBatch = (names: string[]) => {
return client.callTool({
name: 'get_component_batch',
arguments: {names},
})
}

it('requires between 2 and 10 names', async () => {
const tooFew = await callBatch(['Button'])
const tooMany = await callBatch(Array.from({length: 11}, (_, index) => `Component${index}`))

expect(tooFew.isError).toBe(true)
expect(tooMany.isError).toBe(true)
expect(fetch).not.toHaveBeenCalled()
})

it('deduplicates names case-insensitively and preserves result order', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async input => {
const url = new URL(input.toString())
return new Response(`${url.pathname} docs`)
})

const result = await callBatch(['Button', 'button', 'Dialog'])

expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.content).toEqual([
{type: 'text', text: '/product/components/button/llms.txt docs'},
{type: 'text', text: '/product/components/dialog/llms.txt docs'},
])
})

it('starts component fetches in parallel', async () => {
const fetchMock = vi.mocked(fetch)
const pendingResponses: Array<(response: Response) => void> = []
fetchMock.mockImplementation(
() =>
new Promise(resolve => {
pendingResponses.push(resolve)
}),
)

const resultPromise = callBatch(['Button', 'Dialog'])
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))

for (const resolve of pendingResponses) {
resolve(new Response('docs'))
}

await expect(resultPromise).resolves.toMatchObject({
content: [
{type: 'text', text: 'docs'},
{type: 'text', text: 'docs'},
],
})
})

it('returns missing components and fetch failures in-band', async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, {status: 500}))

const result = await callBatch(['MissingComponent', 'button'])

expect(result.isError).not.toBe(true)
expect(result.content).toEqual([
{
type: 'text',
text: '## MissingComponent\n\nStatus: not-found. No component named `MissingComponent` exists in @primer/react. Use `list_components` for valid names.',
},
{
type: 'text',
text: '## Button\n\nStatus: fetch-error. Failed to load documentation for Button.',
},
])
})

it('times out stalled requests without leaving timers active', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {once: true})
}),
)

const resultPromise = callBatch(['Button', 'Dialog'])
await vi.advanceTimersByTimeAsync(10_000)

await expect(resultPromise).resolves.toMatchObject({
content: [
{type: 'text', text: '## Button\n\nStatus: fetch-error. Failed to load documentation for Button.'},
{type: 'text', text: '## Dialog\n\nStatus: fetch-error. Failed to load documentation for Dialog.'},
],
})
expect(vi.getTimerCount()).toBe(0)
})
})
67 changes: 67 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,73 @@ server.registerTool(
},
)

server.registerTool(
'get_component_batch',
{
description:
'Retrieve documentation for multiple Primer React components in one call. ' +
'Pass all component names you need at once. Docs are fetched in parallel to avoid repeated MCP round-trips when resolving several components. ' +
'For a single component, prefer get_component instead.',
inputSchema: {
names: z
.array(z.string())
.min(2)
.max(10)
.describe('Component names to retrieve (e.g. ["ActionMenu", "Button", "Pagination"])'),
},
annotations: {readOnlyHint: true},
},
async ({names}) => {
const seenNames = new Set<string>()
const deduped = names.filter(name => {
const normalizedName = name.toLowerCase()
if (seenNames.has(normalizedName)) {
return false
}
seenNames.add(normalizedName)
return true
})
const components = listComponents()

const results = await Promise.all(
deduped.map(async name => {
const match = components.find(
component => component.name === name || component.name.toLowerCase() === name.toLowerCase(),
)
if (!match) {
return {
type: 'text' as const,
text: `## ${name}\n\nStatus: not-found. No component named \`${name}\` exists in @primer/react. Use \`list_components\` for valid names.`,
}
}

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)

try {
const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
const llmsResponse = await fetch(llmsUrl, {signal: controller.signal})
if (llmsResponse.ok) {
const text = await llmsResponse.text()
return {type: 'text' as const, text}
}
} catch (_: unknown) {
// Fall through to the in-band error result so other component requests can succeed.
} finally {
clearTimeout(timeout)
}

return {
type: 'text' as const,
text: `## ${match.name}\n\nStatus: fetch-error. Failed to load documentation for ${match.name}.`,
}
}),
)

return {content: results}
},
)

server.registerTool(
'get_component_examples',
{
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "rolldown.config.ts"]
"include": ["src", "rolldown.config.ts", "vitest.config.ts"]
}
8 changes: 8 additions & 0 deletions packages/mcp/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {defineConfig} from '@primer/vitest-config/config'

export default defineConfig({
test: {
environment: 'node',
detectAsyncLeaks: true,
},
})
Loading