diff --git a/.changeset/twenty-crabs-lose.md b/.changeset/twenty-crabs-lose.md new file mode 100644 index 00000000000..83e6c804cbe --- /dev/null +++ b/.changeset/twenty-crabs-lose.md @@ -0,0 +1,5 @@ +--- +'@primer/mcp': patch +--- + +MCP: Correct component usage-guideline routes and ConfirmationDialog documentation URLs. diff --git a/packages/mcp/src/primer.ts b/packages/mcp/src/primer.ts index 55dd80cded6..6dd2d0169ca 100644 --- a/packages/mcp/src/primer.ts +++ b/packages/mcp/src/primer.ts @@ -13,6 +13,10 @@ function idToSlug(id: string): string { return 'action-bar' } + if (id === 'confirmationdialog') { + return 'confirmation-dialog' + } + if (id === 'tooltip-v2') { return 'tooltip' } diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 0e47205d692..39bb6a0effc 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -2,6 +2,302 @@ import {Client, InMemoryTransport} from '@modelcontextprotocol/client' import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest' import {server} from './server' +interface UsageGuidelineRoute { + name: string + expectedPath: string + rawIdPath?: string + publication: 'published' | 'unpublished' +} + +const usageGuidelineRoutes: ReadonlyArray = [ + { + name: 'ActionBar', + expectedPath: '/product/components/action-bar/guidelines', + rawIdPath: '/product/components/actionbar/guidelines', + publication: 'published', + }, + { + name: 'ActionList', + expectedPath: '/product/components/action-list/guidelines', + rawIdPath: '/product/components/action_list/guidelines', + publication: 'published', + }, + { + name: 'ActionMenu', + expectedPath: '/product/components/action-menu/guidelines', + rawIdPath: '/product/components/action_menu/guidelines', + publication: 'published', + }, + { + name: 'AnchoredOverlay', + expectedPath: '/product/components/anchored-overlay/guidelines', + rawIdPath: '/product/components/anchored_overlay/guidelines', + publication: 'unpublished', + }, + {name: 'Autocomplete', expectedPath: '/product/components/autocomplete/guidelines', publication: 'published'}, + {name: 'Avatar', expectedPath: '/product/components/avatar/guidelines', publication: 'unpublished'}, + { + name: 'AvatarStack', + expectedPath: '/product/components/avatar-stack/guidelines', + rawIdPath: '/product/components/avatar_stack/guidelines', + publication: 'unpublished', + }, + {name: 'Banner', expectedPath: '/product/components/banner/guidelines', publication: 'published'}, + {name: 'Blankslate', expectedPath: '/product/components/blankslate/guidelines', publication: 'published'}, + { + name: 'BranchName', + expectedPath: '/product/components/branch-name/guidelines', + rawIdPath: '/product/components/branch_name/guidelines', + publication: 'unpublished', + }, + {name: 'Breadcrumbs', expectedPath: '/product/components/breadcrumbs/guidelines', publication: 'published'}, + {name: 'Button', expectedPath: '/product/components/button/guidelines', publication: 'published'}, + { + name: 'IconButton', + expectedPath: '/product/components/icon-button/guidelines', + rawIdPath: '/product/components/icon_button/guidelines', + publication: 'published', + }, + { + name: 'ButtonGroup', + expectedPath: '/product/components/button-group/guidelines', + rawIdPath: '/product/components/button_group/guidelines', + publication: 'published', + }, + {name: 'Card', expectedPath: '/product/components/card/guidelines', publication: 'unpublished'}, + {name: 'Checkbox', expectedPath: '/product/components/checkbox/guidelines', publication: 'unpublished'}, + { + name: 'CheckboxGroup', + expectedPath: '/product/components/checkbox-group/guidelines', + rawIdPath: '/product/components/checkbox_group/guidelines', + publication: 'published', + }, + { + name: 'CircleBadge', + expectedPath: '/product/components/circle-badge/guidelines', + rawIdPath: '/product/components/circle_badge/guidelines', + publication: 'unpublished', + }, + { + name: 'ConfirmationDialog', + expectedPath: '/product/components/confirmation-dialog/guidelines', + rawIdPath: '/product/components/confirmationdialog/guidelines', + publication: 'published', + }, + { + name: 'CounterLabel', + expectedPath: '/product/components/counter-label/guidelines', + rawIdPath: '/product/components/counter_label/guidelines', + publication: 'unpublished', + }, + { + name: 'DataTable', + expectedPath: '/product/components/data-table/guidelines', + rawIdPath: '/product/components/data_table/guidelines', + publication: 'published', + }, + {name: 'Details', expectedPath: '/product/components/details/guidelines', publication: 'unpublished'}, + { + name: 'Dialog', + expectedPath: '/product/components/dialog/guidelines', + rawIdPath: '/product/components/dialog_v2/guidelines', + publication: 'published', + }, + { + name: 'FeatureFlags', + expectedPath: '/product/components/feature-flags/guidelines', + rawIdPath: '/product/components/feature_flags/guidelines', + publication: 'unpublished', + }, + {name: 'Flash', expectedPath: '/product/components/flash/guidelines', publication: 'unpublished'}, + { + name: 'FormControl', + expectedPath: '/product/components/form-control/guidelines', + rawIdPath: '/product/components/form_control/guidelines', + publication: 'unpublished', + }, + {name: 'Header', expectedPath: '/product/components/header/guidelines', publication: 'unpublished'}, + {name: 'Heading', expectedPath: '/product/components/heading/guidelines', publication: 'unpublished'}, + {name: 'Hidden', expectedPath: '/product/components/hidden/guidelines', publication: 'unpublished'}, + { + name: 'InlineMessage', + expectedPath: '/product/components/inline-message/guidelines', + rawIdPath: '/product/components/inline_message/guidelines', + publication: 'unpublished', + }, + {name: 'KeybindingHint', expectedPath: '/product/components/KeybindingHint/guidelines', publication: 'unpublished'}, + {name: 'Label', expectedPath: '/product/components/label/guidelines', publication: 'unpublished'}, + { + name: 'LabelGroup', + expectedPath: '/product/components/label-group/guidelines', + rawIdPath: '/product/components/label_group/guidelines', + publication: 'unpublished', + }, + {name: 'Link', expectedPath: '/product/components/link/guidelines', publication: 'published'}, + { + name: 'NavList', + expectedPath: '/product/components/nav-list/guidelines', + rawIdPath: '/product/components/nav_list/guidelines', + publication: 'unpublished', + }, + {name: 'Octicon', expectedPath: '/product/components/octicon/guidelines', publication: 'unpublished'}, + {name: 'Overlay', expectedPath: '/product/components/overlay/guidelines', publication: 'unpublished'}, + { + name: 'PageHeader', + expectedPath: '/product/components/page-header/guidelines', + rawIdPath: '/product/components/page_header/guidelines', + publication: 'published', + }, + { + name: 'PageLayout', + expectedPath: '/product/components/page-layout/guidelines', + rawIdPath: '/product/components/page_layout/guidelines', + publication: 'unpublished', + }, + {name: 'Pagehead', expectedPath: '/product/components/pagehead/guidelines', publication: 'unpublished'}, + {name: 'Pagination', expectedPath: '/product/components/pagination/guidelines', publication: 'published'}, + {name: 'Popover', expectedPath: '/product/components/popover/guidelines', publication: 'published'}, + {name: 'Portal', expectedPath: '/product/components/portal/guidelines', publication: 'unpublished'}, + { + name: 'ProgressBar', + expectedPath: '/product/components/progress-bar/guidelines', + rawIdPath: '/product/components/progress_bar/guidelines', + publication: 'unpublished', + }, + {name: 'Radio', expectedPath: '/product/components/radio/guidelines', publication: 'unpublished'}, + { + name: 'RadioGroup', + expectedPath: '/product/components/radio-group/guidelines', + rawIdPath: '/product/components/radio_group/guidelines', + publication: 'published', + }, + { + name: 'RelativeTime', + expectedPath: '/product/components/relative-time/guidelines', + rawIdPath: '/product/components/relative_time/guidelines', + publication: 'published', + }, + { + name: 'SegmentedControl', + expectedPath: '/product/components/segmented-control/guidelines', + rawIdPath: '/product/components/segmented_control/guidelines', + publication: 'published', + }, + {name: 'Select', expectedPath: '/product/components/select/guidelines', publication: 'published'}, + { + name: 'SelectPanel', + expectedPath: '/product/components/select-panel/guidelines', + rawIdPath: '/product/components/select_panel/guidelines', + publication: 'published', + }, + { + name: 'SkeletonBox', + expectedPath: '/product/components/skeleton-box/guidelines', + rawIdPath: '/product/components/skeleton_box/guidelines', + publication: 'unpublished', + }, + { + name: 'SkeletonAvatar', + expectedPath: '/product/components/skeleton-avatar/guidelines', + rawIdPath: '/product/components/skeleton_avatar/guidelines', + publication: 'unpublished', + }, + { + name: 'SkeletonText', + expectedPath: '/product/components/skeleton-text/guidelines', + rawIdPath: '/product/components/skeleton_text/guidelines', + publication: 'unpublished', + }, + {name: 'Spinner', expectedPath: '/product/components/spinner/guidelines', publication: 'unpublished'}, + { + name: 'SplitPageLayout', + expectedPath: '/product/components/split-page-layout/guidelines', + rawIdPath: '/product/components/split_page_layout/guidelines', + publication: 'unpublished', + }, + {name: 'Stack', expectedPath: '/product/components/stack/guidelines', publication: 'unpublished'}, + { + name: 'StateLabel', + expectedPath: '/product/components/state-label/guidelines', + rawIdPath: '/product/components/state_label/guidelines', + publication: 'unpublished', + }, + { + name: 'SubNav', + expectedPath: '/product/components/sub-nav/guidelines', + rawIdPath: '/product/components/sub_nav/guidelines', + publication: 'unpublished', + }, + { + name: 'TabNav', + expectedPath: '/product/components/tab-nav/guidelines', + rawIdPath: '/product/components/tab_nav/guidelines', + publication: 'unpublished', + }, + {name: 'Text', expectedPath: '/product/components/text/guidelines', publication: 'unpublished'}, + { + name: 'TextInput', + expectedPath: '/product/components/text-input/guidelines', + rawIdPath: '/product/components/text_input/guidelines', + publication: 'published', + }, + { + name: 'TextInputWithTokens', + expectedPath: '/product/components/text-input-with-tokens/guidelines', + rawIdPath: '/product/components/text_input_with_tokens/guidelines', + publication: 'published', + }, + {name: 'Textarea', expectedPath: '/product/components/textarea/guidelines', publication: 'published'}, + {name: 'Timeline', expectedPath: '/product/components/timeline/guidelines', publication: 'unpublished'}, + { + name: 'ToggleSwitch', + expectedPath: '/product/components/toggle-switch/guidelines', + rawIdPath: '/product/components/toggle_switch/guidelines', + publication: 'published', + }, + {name: 'Token', expectedPath: '/product/components/token/guidelines', publication: 'published'}, + {name: 'Tooltip', expectedPath: '/product/components/tooltip/guidelines', publication: 'published'}, + { + name: 'TopicTag', + expectedPath: '/product/components/topic-tag/guidelines', + rawIdPath: '/product/components/topic_tag/guidelines', + publication: 'unpublished', + }, + { + name: 'TreeView', + expectedPath: '/product/components/tree-view/guidelines', + rawIdPath: '/product/components/tree_view/guidelines', + publication: 'published', + }, + {name: 'Truncate', expectedPath: '/product/components/truncate/guidelines', publication: 'unpublished'}, + { + name: 'UnderlineNav', + expectedPath: '/product/components/underline-nav/guidelines', + rawIdPath: '/product/components/underline_nav/guidelines', + publication: 'published', + }, + {name: 'AriaAlert', expectedPath: '/product/components/aria-alert/guidelines', publication: 'unpublished'}, + {name: 'AriaStatus', expectedPath: '/product/components/aria-status/guidelines', publication: 'unpublished'}, + { + name: 'FilteredSearch', + expectedPath: '/product/components/filtered-search/guidelines', + rawIdPath: '/product/components/filtered_search/guidelines', + publication: 'unpublished', + }, + { + name: 'IssueLabel', + expectedPath: '/product/components/issue-label/guidelines', + rawIdPath: '/product/components/issue_label/guidelines', + publication: 'unpublished', + }, + { + name: 'UnderlinePanels', + expectedPath: '/product/components/underline-panels/guidelines', + rawIdPath: '/product/components/underline_panels/guidelines', + publication: 'published', + }, +] + describe('MCP server', () => { const client = new Client( {name: 'mcp-server-test', version: '1.0.0'}, @@ -50,6 +346,12 @@ describe('MCP server', () => { }) } + const responseWithFinalUrl = (body: string, url: string) => { + const response = new Response(body) + Object.defineProperty(response, 'url', {value: url}) + return response + } + it('documents which component tool to use for each supported count', async () => { const {tools} = await client.listTools() const getComponent = tools.find(tool => tool.name === 'get_component') @@ -110,6 +412,142 @@ describe('MCP server', () => { ) }) + it('covers every advertised component with a named usage-guidelines routing case', async () => { + const result = await client.callTool({name: 'list_components'}) + expect(result.isError).not.toBe(true) + expect(result.content).toHaveLength(1) + + const [content] = result.content + if (content.type !== 'text') { + throw new Error('Expected list_components to return a text component list') + } + + const names = content.text + .split('\n') + .filter(line => line.startsWith('- ')) + .map(line => line.slice(2)) + + expect([...new Set(names)].sort()).toEqual(usageGuidelineRoutes.map(route => route.name).sort()) + expect(names.filter((name, index) => names.indexOf(name) !== index).sort()).toEqual([ + 'Dialog', + 'SelectPanel', + 'Tooltip', + 'UnderlineNav', + ]) + expect(fetch).not.toHaveBeenCalled() + }) + + it.each(usageGuidelineRoutes)( + '$name usage request follows its expected route (guidelines $publication)', + async ({name, expectedPath, rawIdPath}) => { + const expectedUrl = `https://primer.style${expectedPath}` + const rawIdUrl = rawIdPath ? `https://primer.style${rawIdPath}` : undefined + const responseBody = `synthetic-${name.toLowerCase()}-usage-body` + const homepageBody = `homepage-body-not-${name.toLowerCase()}-guidance` + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async input => { + const url = input.toString() + + if (url === expectedUrl) { + return responseWithFinalUrl(`

${responseBody}

`, expectedUrl) + } + + if (url === rawIdUrl) { + return responseWithFinalUrl(`

${homepageBody}

`, 'https://primer.style/') + } + + throw new Error(`Unexpected fetch request: ${url}`) + }) + + const result = await client.callTool({ + name: 'get_component_usage_guidelines', + arguments: {name}, + }) + + expect(result.isError).not.toBe(true) + expect(result.content).toContainEqual( + expect.objectContaining({ + type: 'text', + text: expect.stringContaining(responseBody), + }), + ) + expect(result.content).not.toContainEqual( + expect.objectContaining({ + type: 'text', + text: expect.stringContaining(homepageBody), + }), + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]?.[0].toString()).toBe(expectedUrl) + }, + ) + + it.each(['ConfirmationDialog', 'confirmationdialog'])( + 'retrieves ConfirmationDialog documentation for %s from its published URL', + async name => { + const url = 'https://primer.style/product/components/confirmation-dialog/llms.txt' + const body = '# ConfirmationDialog\n\nSynthetic component documentation.' + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async input => { + if (input.toString() === url) { + return new Response(body) + } + + if (input.toString() === 'https://primer.style/product/components/confirmationdialog/llms.txt') { + return new Response(null, {status: 404}) + } + + throw new Error(`Unexpected fetch request: ${input}`) + }) + + const result = await client.callTool({name: 'get_component', arguments: {name}}) + + expect(result.isError).not.toBe(true) + expect(result.content).toEqual([{type: 'text', text: body}]) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]?.[0].toString()).toBe(url) + }, + ) + + it.each(['ConfirmationDialog', 'confirmationdialog'])( + 'retrieves ConfirmationDialog documentation in a mixed batch for %s from its published URL', + async name => { + const confirmationUrl = 'https://primer.style/product/components/confirmation-dialog/llms.txt' + const buttonUrl = 'https://primer.style/product/components/button/llms.txt' + const confirmationBody = '# ConfirmationDialog\n\nSynthetic component documentation.' + const buttonBody = '# Button\n\nSynthetic component documentation.' + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async input => { + const url = input.toString() + if (url === confirmationUrl) { + return new Response(confirmationBody) + } + + if (url === buttonUrl) { + return new Response(buttonBody) + } + + if (url === 'https://primer.style/product/components/confirmationdialog/llms.txt') { + return new Response(null, {status: 404}) + } + + throw new Error(`Unexpected fetch request: ${url}`) + }) + + const result = await callBatch([name, 'Button']) + + expect(result.isError).not.toBe(true) + expect(result.content).toEqual([ + {type: 'text', text: confirmationBody}, + {type: 'text', text: buttonBody}, + ]) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls.map(([input]) => input.toString()).sort()).toEqual( + [confirmationUrl, buttonUrl].sort(), + ) + }, + ) + it('requires between 2 and 10 names', async () => { const tooFew = await callBatch(['Button']) const tooMany = await callBatch(Array.from({length: 11}, (_, index) => `Component${index}`)) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 7fb21e7a897..3440e40889b 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -314,7 +314,7 @@ ${text}`, } } - const url = new URL(`/product/components/${match.id}/guidelines`, 'https://primer.style') + const url = new URL(`/product/components/${match.slug}/guidelines`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) {