Skip to content

Commit 1bcbbc5

Browse files
authored
feat: add MCP configuration page to frontend settings UI (#1211)
* feat: add MCP configuration page to frontend settings UI Adds a dedicated MCP configuration page at /mcp-config that displays: - SSE endpoint URL for connecting MCP clients with one-click copy - Claude Desktop configuration JSON with copy-to-clipboard - OAuth authorization URL for browser-based MCP client login - Documentation link to the MCP Server README - Accordion listing all 22 available tools grouped by category (Read, Simulate, Write) and 2 resources Adds VITE_MCP_SERVER_URL environment variable (defaults to http://localhost:8091), Bot icon nav item in the sidebar, and route in App.tsx. * fix: resolve ESLint consistent-type-imports violation in test mock * fix: add clipboard error handling, timer cleanup, and stable README link - Wrap navigator.clipboard.writeText in try/catch to prevent unhandled rejections - Store timeout ID in ref and clear on unmount to prevent setState after unmount - Replace repo-relative href with stable GitHub URL for the MCP Server README * refactor: improve test isolation with afterEach cleanup and seeded context --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 323ce2e commit 1bcbbc5

6 files changed

Lines changed: 556 additions & 0 deletions

File tree

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ReconciliationPage } from '@/pages/reconciliation'
3838
import { ReconciliationDetailPage } from '@/pages/reconciliation/detail'
3939
import { DashboardPage } from '@/pages/dashboard'
4040
import { ManifestsPage } from '@/pages/manifests'
41+
import { McpConfigPage } from '@/pages/mcp-config'
4142

4243
// Placeholder page components - replaced as each page task is implemented
4344
function PlaceholderPage({ title }: { title: string }) {
@@ -160,6 +161,7 @@ function AppShellLayout() {
160161
<Route path="/gateway-mappings" element={<MappingsPage />} />
161162
<Route path="/gateway-mappings/:mappingId" element={<MappingDetailPage />} />
162163
<Route path="/manifests" element={<ManifestsPage />} />
164+
<Route path="/mcp-config" element={<McpConfigPage />} />
163165
<Route path="/audit-log" element={<AuditLogPage />} />
164166

165167
{/* Platform-only routes */}

frontend/src/components/layout/sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ClipboardList,
1818
CheckSquare,
1919
FileJson,
20+
Bot,
2021
} from 'lucide-react'
2122
import { cn } from '@/lib/utils'
2223

@@ -41,6 +42,7 @@ const TENANT_NAV_ITEMS: NavItem[] = [
4142
{ label: 'Reference Data', href: '/reference-data', icon: Database },
4243
{ label: 'Gateway Mappings', href: '/gateway-mappings', icon: Map },
4344
{ label: 'Manifests', href: '/manifests', icon: FileJson },
45+
{ label: 'MCP Config', href: '/mcp-config', icon: Bot },
4446
{ label: 'Audit Log', href: '/audit-log', icon: ClipboardList },
4547
]
4648

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { render, screen, waitFor, act } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { MemoryRouter } from 'react-router-dom'
5+
import { McpConfigPage } from './index'
6+
7+
// Mock tenant context for tests that need a specific tenant
8+
vi.mock('@/contexts/tenant-context', () => ({
9+
useTenantContext: vi.fn(() => ({
10+
tenantSlug: 'test-tenant',
11+
currentTenant: { id: 'tid', slug: 'test-tenant', name: 'Test Tenant' },
12+
isPlatformAdmin: false,
13+
switchTenant: vi.fn(),
14+
clearTenant: vi.fn(),
15+
})),
16+
}))
17+
18+
import { useTenantContext } from '@/contexts/tenant-context'
19+
20+
function Wrapper({ children }: { children: React.ReactNode }) {
21+
return <MemoryRouter>{children}</MemoryRouter>
22+
}
23+
24+
const defaultTenantContext = {
25+
tenantSlug: 'test-tenant',
26+
currentTenant: { id: 'tid', slug: 'test-tenant', name: 'Test Tenant' },
27+
isPlatformAdmin: false,
28+
switchTenant: vi.fn(),
29+
clearTenant: vi.fn(),
30+
}
31+
32+
describe('McpConfigPage', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
vi.mocked(useTenantContext).mockReturnValue(defaultTenantContext)
36+
})
37+
38+
afterEach(() => {
39+
vi.unstubAllGlobals()
40+
})
41+
42+
describe('rendering', () => {
43+
it('renders page title and description', () => {
44+
render(<McpConfigPage />, { wrapper: Wrapper })
45+
46+
expect(screen.getByRole('heading', { name: /MCP Configuration/i })).toBeInTheDocument()
47+
expect(screen.getByText(/Model Context Protocol/i)).toBeInTheDocument()
48+
})
49+
50+
it('renders server connection section with SSE URL', () => {
51+
render(<McpConfigPage />, { wrapper: Wrapper })
52+
53+
expect(screen.getByText('Server Connection')).toBeInTheDocument()
54+
expect(screen.getByTestId('sse-url')).toHaveTextContent('/sse')
55+
})
56+
57+
it('renders Claude Desktop config section with JSON', () => {
58+
render(<McpConfigPage />, { wrapper: Wrapper })
59+
60+
expect(screen.getByText('Claude Desktop Configuration')).toBeInTheDocument()
61+
const configEl = screen.getByTestId('claude-desktop-config')
62+
expect(configEl).toHaveTextContent('mcpServers')
63+
expect(configEl).toHaveTextContent('meridian')
64+
expect(configEl).toHaveTextContent('mcp-remote')
65+
})
66+
67+
it('renders OAuth authorization section', () => {
68+
render(<McpConfigPage />, { wrapper: Wrapper })
69+
70+
expect(screen.getByText('OAuth Authorization')).toBeInTheDocument()
71+
expect(screen.getByTestId('oauth-url')).toHaveTextContent('/oauth/authorize')
72+
})
73+
74+
it('renders documentation link', () => {
75+
render(<McpConfigPage />, { wrapper: Wrapper })
76+
77+
const link = screen.getByTestId('readme-link')
78+
expect(link).toBeInTheDocument()
79+
expect(link).toHaveTextContent('MCP Server README')
80+
expect(link).toHaveAttribute('target', '_blank')
81+
})
82+
83+
it('renders MCP tools accordion', () => {
84+
render(<McpConfigPage />, { wrapper: Wrapper })
85+
86+
expect(screen.getByText('Available Capabilities')).toBeInTheDocument()
87+
expect(screen.getByText('Read Tools')).toBeInTheDocument()
88+
expect(screen.getByText('Simulate Tools')).toBeInTheDocument()
89+
expect(screen.getByText('Write Tools')).toBeInTheDocument()
90+
expect(screen.getByText('Resources')).toBeInTheDocument()
91+
})
92+
93+
it('shows tenant badge when tenant is selected', () => {
94+
vi.mocked(useTenantContext).mockReturnValue({
95+
tenantSlug: 'my-tenant',
96+
currentTenant: { id: 'tid', slug: 'my-tenant', name: 'My Tenant' },
97+
isPlatformAdmin: false,
98+
switchTenant: vi.fn(),
99+
clearTenant: vi.fn(),
100+
})
101+
102+
render(<McpConfigPage />, { wrapper: Wrapper })
103+
104+
expect(screen.getByText('Tenant: my-tenant')).toBeInTheDocument()
105+
})
106+
107+
it('does not show tenant badge when no tenant selected', () => {
108+
vi.mocked(useTenantContext).mockReturnValue({
109+
tenantSlug: null,
110+
currentTenant: null,
111+
isPlatformAdmin: true,
112+
switchTenant: vi.fn(),
113+
clearTenant: vi.fn(),
114+
})
115+
116+
render(<McpConfigPage />, { wrapper: Wrapper })
117+
118+
expect(screen.queryByText(/Tenant:/)).not.toBeInTheDocument()
119+
})
120+
})
121+
122+
describe('copy to clipboard', () => {
123+
it('copies SSE URL on button click', async () => {
124+
const writeText = vi.fn().mockResolvedValue(undefined)
125+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
126+
127+
render(<McpConfigPage />, { wrapper: Wrapper })
128+
129+
const copyButton = screen.getByRole('button', { name: /Copy SSE URL/i })
130+
await act(async () => {
131+
copyButton.click()
132+
})
133+
134+
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/sse'))
135+
})
136+
137+
it('shows Copied! feedback after clicking copy', async () => {
138+
const writeText = vi.fn().mockResolvedValue(undefined)
139+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
140+
141+
render(<McpConfigPage />, { wrapper: Wrapper })
142+
143+
const copyButton = screen.getByRole('button', { name: /Copy SSE URL/i })
144+
await act(async () => {
145+
copyButton.click()
146+
})
147+
148+
await waitFor(() => {
149+
expect(screen.getByText('Copied!')).toBeInTheDocument()
150+
})
151+
})
152+
153+
it('copies Claude Desktop config on button click', async () => {
154+
const writeText = vi.fn().mockResolvedValue(undefined)
155+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
156+
157+
render(<McpConfigPage />, { wrapper: Wrapper })
158+
159+
const copyButton = screen.getByRole('button', { name: /Copy Claude Desktop config/i })
160+
await act(async () => {
161+
copyButton.click()
162+
})
163+
164+
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('mcpServers'))
165+
})
166+
167+
it('copies OAuth URL on button click', async () => {
168+
const writeText = vi.fn().mockResolvedValue(undefined)
169+
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
170+
171+
render(<McpConfigPage />, { wrapper: Wrapper })
172+
173+
const copyButton = screen.getByRole('button', { name: /Copy OAuth URL/i })
174+
await act(async () => {
175+
copyButton.click()
176+
})
177+
178+
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/oauth/authorize'))
179+
})
180+
})
181+
182+
describe('MCP tools accordion', () => {
183+
it('expands Read Tools accordion and shows tool names', async () => {
184+
const user = userEvent.setup({ writeToClipboard: false })
185+
render(<McpConfigPage />, { wrapper: Wrapper })
186+
187+
const readToolsTrigger = screen.getByRole('button', { name: /Read Tools/i })
188+
await user.click(readToolsTrigger)
189+
190+
await waitFor(() => {
191+
expect(screen.getByText('meridian_economy_structure')).toBeInTheDocument()
192+
expect(screen.getByText('meridian_instruments_list')).toBeInTheDocument()
193+
})
194+
})
195+
196+
it('expands Simulate Tools accordion and shows tool names', async () => {
197+
const user = userEvent.setup({ writeToClipboard: false })
198+
render(<McpConfigPage />, { wrapper: Wrapper })
199+
200+
const simulateTrigger = screen.getByRole('button', { name: /Simulate Tools/i })
201+
await user.click(simulateTrigger)
202+
203+
await waitFor(() => {
204+
expect(screen.getByText('meridian_cel_validate')).toBeInTheDocument()
205+
expect(screen.getByText('meridian_saga_simulate')).toBeInTheDocument()
206+
})
207+
})
208+
209+
it('expands Write Tools accordion and shows tool names', async () => {
210+
const user = userEvent.setup({ writeToClipboard: false })
211+
render(<McpConfigPage />, { wrapper: Wrapper })
212+
213+
const writeTrigger = screen.getByRole('button', { name: /Write Tools/i })
214+
await user.click(writeTrigger)
215+
216+
await waitFor(() => {
217+
expect(screen.getByText('meridian_manifest_apply')).toBeInTheDocument()
218+
})
219+
})
220+
221+
it('expands Resources accordion and shows resource URIs', async () => {
222+
const user = userEvent.setup({ writeToClipboard: false })
223+
render(<McpConfigPage />, { wrapper: Wrapper })
224+
225+
const resourcesTrigger = screen.getByRole('button', { name: /Resources/i })
226+
await user.click(resourcesTrigger)
227+
228+
await waitFor(() => {
229+
expect(screen.getByText('meridian://tenant/manifest/current')).toBeInTheDocument()
230+
})
231+
})
232+
233+
it('displays tool and resource counts in badges', () => {
234+
render(<McpConfigPage />, { wrapper: Wrapper })
235+
236+
// Total: 13 read + 8 simulate + 1 write = 22 tools, 2 resources
237+
expect(screen.getByText('22 tools')).toBeInTheDocument()
238+
expect(screen.getByText('2 resources')).toBeInTheDocument()
239+
})
240+
})
241+
})

0 commit comments

Comments
 (0)