Skip to content

ddevilz/webmcp-testing

Repository files navigation

@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/testing

Setup (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 matchers

Core 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 + history

createMockAgent(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

  1. setupWebMCPTesting() installs MockModelContext on navigator.modelContext
  2. Your component's useEffect runs navigator.modelContext.registerTool(...) as normal
  3. MockModelContext stores the tool in memory — no browser, no extension needed
  4. createMockAgent() calls _invoke() on MockModelContext, running your execute() function directly
  5. The tool's execute() has full access to React state via closure — it runs in the same JS context
  6. afterEach resets MockModelContext automatically to keep tests isolated

License

MIT - see LICENSE file for details

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors