@devaj/webmcp-testing
v1.0.2
Published
Testing utilities for WebMCP — MockModelContext, createMockAgent, renderWithWebMCP, and custom Vitest/Jest matchers
Downloads
148
Maintainers
Readme
@webmcp/testing
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.
Install
npm install --save-dev @webmcp/testingSetup (Vitest)
// 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 matchersCore API
MockModelContext
In-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 + historycreateMockAgent(options?)
Programmatic 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()renderWithWebMCP(ui, options?)
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 } }
)Custom Matchers
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')Test Recipes
Tool registration lifecycle
it('registers tools on mount and removes them on unmount', () => {
const { unmount } = renderWithWebMCP(<TaskList />)
expect('tasks_add').toBeRegisteredTool()
unmount()
expect('tasks_add').not.toBeRegisteredTool()
})Agent calls a tool → React state updates
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()
})Testing loading states
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()
})Destructive actions with user confirmation
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()
})Schema validation
it('rejects calls with invalid params', async () => {
const { agent } = renderWithWebMCP(<TaskList />)
await expect(agent.callTool('tasks_add', { priority: 'high' }))
.rejects.toThrow(/required/) // missing 'title'
})Package Exports
| 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 |
How It Works
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
License
MIT - see LICENSE file for details
