Testing utilities for WebMCP-powered React apps — MockModelContext, createMockAgent, renderWithWebMCP, and custom Vitest/Jest matchers.
The gap this fills: WebMCP tools registered via navigator.modelContext are completely untestable today without Chrome 146 Canary + the MCP-B browser extension + a real AI agent. This package gives you everything you need to test WebMCP tools in Node.js/jsdom CI pipelines with zero browser required.
npm install --save-dev @webmcp/testing// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
})
// src/test/setup.ts
import { setupWebMCPTesting } from '@webmcp/testing'
setupWebMCPTesting() // installs MockModelContext + registers all matchersIn-memory implementation of navigator.modelContext. Installed automatically by setupWebMCPTesting().
import { getMockModelContext } from '@webmcp/testing'
const ctx = getMockModelContext()
ctx.getRegisteredTools() // Map<string, ModelContextTool>
ctx.getRegisteredTool('name') // ModelContextTool | undefined
ctx.getCallHistory() // ToolCallRecord[]
ctx.reset() // clear tools + historyProgrammatic agent that fires tool calls exactly as a real AI would.
import { createMockAgent } from '@webmcp/testing'
const agent = createMockAgent({
latency: 50, // artificial delay (ms)
validateSchema: true, // validates params vs inputSchema
onRequestUserInteraction: (r) => r(true), // auto-approve confirmations
onToolCall: (record) => console.log(record),
})
const result = await agent.callTool('tasks_add', { title: 'Buy milk', priority: 'high' })
agent.getCallHistory() // ToolCallRecord[]
agent.getCallsFor('name') // ToolCallRecord[]
agent.reset()React Testing Library wrapper — returns everything RTL's render() returns, plus agent and mockContext.
import { renderWithWebMCP } from '@webmcp/testing/react'
const { agent, mockContext, getByText, findByRole, unmount } = renderWithWebMCP(
<TaskList />,
{ agentOptions: { latency: 100 } }
)All matchers are registered globally after setupWebMCPTesting().
// Registration
expect('tool_name').toBeRegisteredTool()
expect('tool_name').toBeRegisteredTool({ description: 'Add a task' })
expect('delete_account').toHaveAnnotation('destructiveHint', true)
expect(navigator.modelContext).toHaveToolCount(3)
// Call assertions (on MockAgent)
expect(agent).toHaveCalledTool('tasks_add')
expect(agent).not.toHaveCalledTool('tasks_delete')
expect(agent).toHaveCalledToolWith('tasks_add', { title: 'Buy milk' })
expect(agent).toHaveCalledToolTimes('tasks_add', 2)
expect(agent).toHaveToolReturnedSuccess('tasks_add')
expect(agent).toHaveToolReturnedError('tasks_add', 'Validation failed')
expect(agent).toHaveRequestedUserInteraction('delete_account')it('registers tools on mount and removes them on unmount', () => {
const { unmount } = renderWithWebMCP(<TaskList />)
expect('tasks_add').toBeRegisteredTool()
unmount()
expect('tasks_add').not.toBeRegisteredTool()
})it('adds a task when agent calls tasks_add', async () => {
const { agent } = renderWithWebMCP(<TaskList />)
await agent.callTool('tasks_add', { title: 'Buy milk', priority: 'medium' })
expect(await screen.findByText('Buy milk')).toBeInTheDocument()
})it('shows spinner while tool runs', async () => {
const { agent } = renderWithWebMCP(<TaskList />, { agentOptions: { latency: 100 } })
const callPromise = agent.callTool('tasks_add', { title: 'x', priority: 'low' })
expect(screen.getByText('AI is adding...')).toBeInTheDocument()
await callPromise
expect(screen.queryByText('AI is adding...')).not.toBeInTheDocument()
})it('cancels when user declines', async () => {
const { agent } = renderWithWebMCP(<Settings />, {
agentOptions: { onRequestUserInteraction: resolve => resolve(false) }
})
await agent.callTool('delete_account', {})
expect(agent).toHaveRequestedUserInteraction('delete_account')
expect(screen.queryByText('Account deleted')).not.toBeInTheDocument()
})it('rejects calls with invalid params', async () => {
const { agent } = renderWithWebMCP(<TaskList />)
await expect(agent.callTool('tasks_add', { priority: 'high' }))
.rejects.toThrow(/required/) // missing 'title'
})| Import | Contents |
|---|---|
@webmcp/testing |
Core: MockModelContext, createMockAgent, setupWebMCPTesting, types |
@webmcp/testing/react |
renderWithWebMCP |
@webmcp/testing/matchers |
webMCPMatchers (for manual expect.extend()) |
@webmcp/testing/setup |
setupWebMCPTesting + re-exports |
@webmcp/testing/playwright |
createPlaywrightWebMCPFixture |
setupWebMCPTesting()installsMockModelContextonnavigator.modelContext- Your component's
useEffectrunsnavigator.modelContext.registerTool(...)as normal MockModelContextstores the tool in memory — no browser, no extension neededcreateMockAgent()calls_invoke()onMockModelContext, running yourexecute()function directly- The tool's
execute()has full access to React state via closure — it runs in the same JS context afterEachresetsMockModelContextautomatically to keep tests isolated
MIT - see LICENSE file for details