@primer/mcp 0.4.0-rc.fbf889cc5 → 0.5.0-rc.01176969a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as server } from "./server-BTJ9W5jN.js";
1
+ import { t as server } from "./server-rl4CHqO6.js";
2
2
  export { server };
@@ -692,7 +692,7 @@ function runStylelint(css) {
692
692
  //#region src/server.ts
693
693
  const server = new McpServer({
694
694
  name: "Primer",
695
- version: "0.4.0"
695
+ version: "0.5.0"
696
696
  });
697
697
  const turndownService = new TurndownService();
698
698
  const allTokensWithGuidelines = loadAllTokensWithGuidelines();
@@ -766,6 +766,43 @@ server.registerTool("get_component", {
766
766
  content: []
767
767
  };
768
768
  });
769
+ server.registerTool("get_component_batch", {
770
+ 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.",
771
+ inputSchema: { names: z.array(z.string()).min(2).max(10).describe("Component names to retrieve (e.g. [\"ActionMenu\", \"Button\", \"Pagination\"])") },
772
+ annotations: { readOnlyHint: true }
773
+ }, async ({ names }) => {
774
+ const seenNames = /* @__PURE__ */ new Set();
775
+ const deduped = names.filter((name) => {
776
+ const normalizedName = name.toLowerCase();
777
+ if (seenNames.has(normalizedName)) return false;
778
+ seenNames.add(normalizedName);
779
+ return true;
780
+ });
781
+ const components = listComponents();
782
+ return { content: await Promise.all(deduped.map(async (name) => {
783
+ const match = components.find((component) => component.name === name || component.name.toLowerCase() === name.toLowerCase());
784
+ if (!match) return {
785
+ type: "text",
786
+ text: `## ${name}\n\nStatus: not-found. No component named \`${name}\` exists in @primer/react. Use \`list_components\` for valid names.`
787
+ };
788
+ const controller = new AbortController();
789
+ const timeout = setTimeout(() => controller.abort(), 1e4);
790
+ try {
791
+ const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, "https://primer.style");
792
+ const llmsResponse = await fetch(llmsUrl, { signal: controller.signal });
793
+ if (llmsResponse.ok) return {
794
+ type: "text",
795
+ text: await llmsResponse.text()
796
+ };
797
+ } catch (_) {} finally {
798
+ clearTimeout(timeout);
799
+ }
800
+ return {
801
+ type: "text",
802
+ text: `## ${match.name}\n\nStatus: fetch-error. Failed to load documentation for ${match.name}.`
803
+ };
804
+ })) };
805
+ });
769
806
  server.registerTool("get_component_examples", {
770
807
  description: "Get examples for how to use a component from Primer React",
771
808
  inputSchema: { name: z.string().describe("The name of the component to retrieve") },
package/dist/stdio.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as server } from "./server-BTJ9W5jN.js";
1
+ import { t as server } from "./server-rl4CHqO6.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  //#region src/transports/stdio.ts
4
4
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@primer/mcp",
3
3
  "description": "An MCP server that connects AI tools to the Primer Design System",
4
- "version": "0.4.0-rc.fbf889cc5",
4
+ "version": "0.5.0-rc.01176969a",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mcp": "./bin/mcp.js"
@@ -29,6 +29,7 @@
29
29
  "scripts": {
30
30
  "clean": "rimraf dist",
31
31
  "build": "rolldown -c",
32
+ "test": "vitest --run",
32
33
  "type-check": "tsc --noEmit",
33
34
  "watch": "rolldown -c -w"
34
35
  },
@@ -36,16 +37,17 @@
36
37
  "@modelcontextprotocol/sdk": "^1.24.0",
37
38
  "@primer/octicons": "^19.15.5",
38
39
  "@primer/primitives": "10.x || 11.x",
39
- "@primer/react": "^38.30.0",
40
+ "@primer/react": "^38.33.0",
40
41
  "cheerio": "^1.0.0",
41
42
  "turndown": "^7.2.0",
42
43
  "zod": "^4.3.5"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@modelcontextprotocol/inspector": "^0.16.6",
47
+ "@primer/vitest-config": "^0.0.0",
46
48
  "@types/turndown": "^5.0.5",
47
49
  "rimraf": "^6.0.1",
48
- "rolldown": "^1.1.2",
50
+ "rolldown": "^1.1.4",
49
51
  "rolldown-plugin-dts": "^0.26.0",
50
52
  "typescript": "^6.0.3"
51
53
  }
@@ -0,0 +1,125 @@
1
+ import {Client} from '@modelcontextprotocol/sdk/client/index.js'
2
+ import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'
3
+ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'
4
+ import {server} from './server'
5
+
6
+ describe('get_component_batch', () => {
7
+ const client = new Client({name: 'mcp-server-test', version: '1.0.0'})
8
+
9
+ beforeAll(async () => {
10
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
11
+ await server.connect(serverTransport)
12
+ await client.connect(clientTransport)
13
+ })
14
+
15
+ beforeEach(() => {
16
+ vi.stubGlobal('fetch', vi.fn<typeof fetch>())
17
+ })
18
+
19
+ afterEach(() => {
20
+ vi.useRealTimers()
21
+ vi.unstubAllGlobals()
22
+ })
23
+
24
+ afterAll(async () => {
25
+ await client.close()
26
+ await server.close()
27
+ })
28
+
29
+ const callBatch = (names: string[]) => {
30
+ return client.callTool({
31
+ name: 'get_component_batch',
32
+ arguments: {names},
33
+ })
34
+ }
35
+
36
+ it('requires between 2 and 10 names', async () => {
37
+ const tooFew = await callBatch(['Button'])
38
+ const tooMany = await callBatch(Array.from({length: 11}, (_, index) => `Component${index}`))
39
+
40
+ expect(tooFew.isError).toBe(true)
41
+ expect(tooMany.isError).toBe(true)
42
+ expect(fetch).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('deduplicates names case-insensitively and preserves result order', async () => {
46
+ const fetchMock = vi.mocked(fetch)
47
+ fetchMock.mockImplementation(async input => {
48
+ const url = new URL(input.toString())
49
+ return new Response(`${url.pathname} docs`)
50
+ })
51
+
52
+ const result = await callBatch(['Button', 'button', 'Dialog'])
53
+
54
+ expect(fetchMock).toHaveBeenCalledTimes(2)
55
+ expect(result.content).toEqual([
56
+ {type: 'text', text: '/product/components/button/llms.txt docs'},
57
+ {type: 'text', text: '/product/components/dialog/llms.txt docs'},
58
+ ])
59
+ })
60
+
61
+ it('starts component fetches in parallel', async () => {
62
+ const fetchMock = vi.mocked(fetch)
63
+ const pendingResponses: Array<(response: Response) => void> = []
64
+ fetchMock.mockImplementation(
65
+ () =>
66
+ new Promise(resolve => {
67
+ pendingResponses.push(resolve)
68
+ }),
69
+ )
70
+
71
+ const resultPromise = callBatch(['Button', 'Dialog'])
72
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
73
+
74
+ for (const resolve of pendingResponses) {
75
+ resolve(new Response('docs'))
76
+ }
77
+
78
+ await expect(resultPromise).resolves.toMatchObject({
79
+ content: [
80
+ {type: 'text', text: 'docs'},
81
+ {type: 'text', text: 'docs'},
82
+ ],
83
+ })
84
+ })
85
+
86
+ it('returns missing components and fetch failures in-band', async () => {
87
+ vi.mocked(fetch).mockResolvedValue(new Response(null, {status: 500}))
88
+
89
+ const result = await callBatch(['MissingComponent', 'button'])
90
+
91
+ expect(result.isError).not.toBe(true)
92
+ expect(result.content).toEqual([
93
+ {
94
+ type: 'text',
95
+ text: '## MissingComponent\n\nStatus: not-found. No component named `MissingComponent` exists in @primer/react. Use `list_components` for valid names.',
96
+ },
97
+ {
98
+ type: 'text',
99
+ text: '## Button\n\nStatus: fetch-error. Failed to load documentation for Button.',
100
+ },
101
+ ])
102
+ })
103
+
104
+ it('times out stalled requests without leaving timers active', async () => {
105
+ vi.useFakeTimers()
106
+ const fetchMock = vi.mocked(fetch)
107
+ fetchMock.mockImplementation(
108
+ (_input, init) =>
109
+ new Promise((_resolve, reject) => {
110
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {once: true})
111
+ }),
112
+ )
113
+
114
+ const resultPromise = callBatch(['Button', 'Dialog'])
115
+ await vi.advanceTimersByTimeAsync(10_000)
116
+
117
+ await expect(resultPromise).resolves.toMatchObject({
118
+ content: [
119
+ {type: 'text', text: '## Button\n\nStatus: fetch-error. Failed to load documentation for Button.'},
120
+ {type: 'text', text: '## Dialog\n\nStatus: fetch-error. Failed to load documentation for Dialog.'},
121
+ ],
122
+ })
123
+ expect(vi.getTimerCount()).toBe(0)
124
+ })
125
+ })
package/src/server.ts CHANGED
@@ -161,6 +161,73 @@ server.registerTool(
161
161
  },
162
162
  )
163
163
 
164
+ server.registerTool(
165
+ 'get_component_batch',
166
+ {
167
+ description:
168
+ 'Retrieve documentation for multiple Primer React components in one call. ' +
169
+ 'Pass all component names you need at once. Docs are fetched in parallel to avoid repeated MCP round-trips when resolving several components. ' +
170
+ 'For a single component, prefer get_component instead.',
171
+ inputSchema: {
172
+ names: z
173
+ .array(z.string())
174
+ .min(2)
175
+ .max(10)
176
+ .describe('Component names to retrieve (e.g. ["ActionMenu", "Button", "Pagination"])'),
177
+ },
178
+ annotations: {readOnlyHint: true},
179
+ },
180
+ async ({names}) => {
181
+ const seenNames = new Set<string>()
182
+ const deduped = names.filter(name => {
183
+ const normalizedName = name.toLowerCase()
184
+ if (seenNames.has(normalizedName)) {
185
+ return false
186
+ }
187
+ seenNames.add(normalizedName)
188
+ return true
189
+ })
190
+ const components = listComponents()
191
+
192
+ const results = await Promise.all(
193
+ deduped.map(async name => {
194
+ const match = components.find(
195
+ component => component.name === name || component.name.toLowerCase() === name.toLowerCase(),
196
+ )
197
+ if (!match) {
198
+ return {
199
+ type: 'text' as const,
200
+ text: `## ${name}\n\nStatus: not-found. No component named \`${name}\` exists in @primer/react. Use \`list_components\` for valid names.`,
201
+ }
202
+ }
203
+
204
+ const controller = new AbortController()
205
+ const timeout = setTimeout(() => controller.abort(), 10_000)
206
+
207
+ try {
208
+ const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
209
+ const llmsResponse = await fetch(llmsUrl, {signal: controller.signal})
210
+ if (llmsResponse.ok) {
211
+ const text = await llmsResponse.text()
212
+ return {type: 'text' as const, text}
213
+ }
214
+ } catch (_: unknown) {
215
+ // Fall through to the in-band error result so other component requests can succeed.
216
+ } finally {
217
+ clearTimeout(timeout)
218
+ }
219
+
220
+ return {
221
+ type: 'text' as const,
222
+ text: `## ${match.name}\n\nStatus: fetch-error. Failed to load documentation for ${match.name}.`,
223
+ }
224
+ }),
225
+ )
226
+
227
+ return {content: results}
228
+ },
229
+ )
230
+
164
231
  server.registerTool(
165
232
  'get_component_examples',
166
233
  {