| name | mcp-web |
| description | Build frontend apps whose state and actions are exposed to AI agents via MCP. Use when building MCP-Web apps, exposing frontend state to AI, creating AI-controllable web UIs, registering MCP tools, or when the user mentions MCP-Web, MCPWeb, or @mcp-web packages. |
MCP-Web
Build web applications that AI agents can understand and control through the Model Context Protocol.
Design Principle: Structure your app around declarative, reactive state that can be easily exposed to AI agents via tools.
Project Structure
your-project/
├── mcp-web.config.{ts,js} # MCPWeb configuration
├── bridge.ts # Bridge server entry point
├── agent.ts # Agent server (optional, for frontend queries)
├── src/
│ ├── schemas.ts # Zod schemas ONLY (no TypeScript types)
│ ├── types.ts # TypeScript types derived from schemas via z.infer<>
│ ├── states.ts # Declarative reactive state management
│ ├── mcp.ts # MCPWeb instantiation
│ ├── tools.ts # Tool registration (state tools + action tools)
│ ├── queries/ # Frontend-triggered queries (optional)
│ └── <app files> # Your application code
└── package.json
File Organization Guidelines
Co-locate when small: If schemas, types, states, or tools would each be under ~100 lines, keep them in single files (schemas.ts, types.ts, states.ts, tools.ts). Co-location makes it easier to reason about all related code together.
Split when large: Only create subdirectories (e.g., schemas/, states/) when individual files exceed ~150-200 lines OR when there are clear domain boundaries.
Separate schemas from types: Always keep Zod schemas and TypeScript types in separate files:
schemas.ts - Zod schema definitions only
types.ts - TypeScript types derived from schemas using z.infer<>
import type { z } from 'zod';
import type { TodoSchema, ProjectSchema } from './schemas';
export type Todo = z.infer<typeof TodoSchema>;
export type Project = z.infer<typeof ProjectSchema>;
The State-Schema-Tool Pattern
1. Define Schemas First (schemas.ts)
Schemas are your contract with the AI. Use .describe() extensively!
import { z } from 'zod';
export const TodoSchema = z.object({
id: z.string().describe('Unique identifier'),
title: z.string().min(1).describe('Todo title'),
completed: z.boolean().describe('Completion status'),
priority: z.enum(['low', 'medium', 'high']).describe('Priority level'),
}).describe('A single todo item');
2. Create Declarative Reactive State (state.ts)
Use your framework's reactivity for derived values:
let gameState = $state<GameState>(createInitialState());
const validMoves = $derived(getLegalMoves(gameState));
export const state = {
get gameState() { return gameState; },
set gameState(value) { gameState = value; },
get validMoves() { return validMoves; },
};
3. Instantiate MCPWeb (mcp.ts)
import { MCPWeb } from '@mcp-web/core';
import { MCP_WEB_CONFIG } from '../mcp-web.config';
export const mcpWeb = new MCPWeb(MCP_WEB_CONFIG);
4. Register Tools (tools.ts)
Keep all tool registrations in a single tools.ts file when under ~100 lines:
import { mcpWeb } from './mcp';
import { TodosSchema, SettingsSchema } from './schemas';
import { todosAtom, settingsAtom } from './states';
import { getDefaultStore } from 'jotai';
const store = getDefaultStore();
mcpWeb.addStateTools({
name: 'todos',
description: 'List of all todo items',
get: () => store.get(todosAtom),
set: (value) => store.set(todosAtom, value),
schema: TodosSchema,
expand: true,
});
mcpWeb.addTool({
name: 'complete_todo',
description: 'Mark a todo as completed',
handler: (input) => { },
inputSchema: CompleteTodoSchema,
});
State Tools vs Action Tools
Use State Tools (addStateTools) for:
- Simple primitives (strings, numbers, booleans)
- Fixed-shape objects (always same keys)
- Configuration and preferences
Use Action Tools (addTool) for:
- Changing shape of state (add/remove items)
- Validation or business logic
- Updating multiple states atomically
- Operations with side effects
Expanded Tools for Collections
Use expand: true when state includes arrays or records that grow:
mcpWeb.addStateTools({
name: 'todo_app',
description: 'Todo application state',
schema: AppSchema,
get: () => store.app,
set: (value) => { store.app = value; },
expand: true,
});
System-Generated Fields
Mark auto-generated fields with system() to hide from AI:
import { system, id } from '@mcp-web/core';
const TodoSchema = z.object({
id: id(system(z.string().default(() => crypto.randomUUID()))),
created_at: system(z.number().default(() => Date.now())),
title: z.string(),
});
Key Design Rules
Schema Design
- Size: 5-20 properties per setter tool
- Depth: Keep schemas flat
- Collections: Use
expand: true for arrays/records
- Descriptions: Use
.describe() on objects and properties
State Architecture
- Fixed-shape → state tools (
z.object(), z.enum(), primitives)
- Dynamic-shape → action tools (
z.array(), z.record())
- Derived values → reactive computation (don't expose to AI)
Optional Values
Use nullable() instead of optional() (JSON doesn't support undefined):
z.object({ description: z.string().optional() })
z.object({ description: z.string().nullable().default(null) })
Connection State
Monitor connection status to handle reconnection or display UI feedback.
Core Library
mcpWeb.connected
mcpWeb.connecting
const unsubscribe = mcpWeb.onConnectionStateChange(() => {
console.log('Connection:', mcpWeb.connected);
console.log('Connecting:', mcpWeb.connecting);
});
unsubscribe();
React Integration
const { isConnected, isConnecting } = useMCPWeb();
if (isConnecting) return <Spinner />;
if (!isConnected) return <OfflineBanner />;
Multi-Session Support
When multiple instances of your app connect to the same bridge (e.g., multiple browser tabs), each gets a unique session ID. By default, Claude sees them as opaque UUIDs. Use sessionName to give sessions human-readable labels:
const mcpWeb = new MCPWeb({
name: 'Checkers',
description: 'A checkers game',
sessionName: 'Game 1',
});
Key rules:
sessionName is optional — unnamed sessions work as before
- Names must be unique per auth token — the bridge rejects duplicates with a clean
authentication-failed message, and connect() rejects with an Error
- Auth tokens are shared via localStorage across tabs on the same origin, so Claude sees all sessions through one MCP connection
- Session IDs are always fresh UUIDs (not persisted in localStorage)
Dynamic name allocation (e.g., for demos with multiple tabs):
const STORAGE_KEY = 'game-slots';
export function claimGameName(): { name: string; release: () => void } {
const slots: (string | null)[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) || '[]'
);
let index = slots.findIndex((s) => s === null);
if (index === -1) index = slots.length;
const id = crypto.randomUUID();
slots[index] = id;
localStorage.setItem(STORAGE_KEY, JSON.stringify(slots));
return {
name: `Game ${index + 1}`,
release: () => {
const current: (string | null)[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) || '[]'
);
const i = current.indexOf(id);
if (i !== -1) {
current[i] = null;
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
}
},
};
}
const { name, release } = claimGameName();
export const releaseGameName = release;
export const mcpWeb = new MCPWeb({
...MCP_WEB_CONFIG,
sessionName: name,
});
onDestroy(() => releaseGameName());
Development Workflow
- Define schemas with rich descriptions
- Create reactive state using your framework
- Register tools (state tools for fixed shapes, actions for operations)
- Start bridge:
npx tsx bridge.ts (uses new MCPWebBridge(config))
- Start app:
npm run dev
- Configure Claude Desktop with auth token
- Test with AI and iterate
Bridge Setup (bridge.ts)
Important: The bridge class is MCPWebBridge (not Bridge):
#!/usr/bin/env tsx
import { MCPWebBridge } from '@mcp-web/bridge';
import { MCP_WEB_CONFIG } from './mcp-web.config';
new MCPWebBridge(MCP_WEB_CONFIG);
Quick Reference
✅ Do:
- Use declarative reactive state
- Describe schemas extensively
- Use state tools for fixed-shape data
- Use action tools for operations
- Use
expand: true for growing collections
- Mark system fields with
system()
- Use
nullable() instead of optional()
- Keep schemas in
schemas.ts, types in types.ts (separate files)
- Co-locate related code in single files when under ~100 lines each
- Use
onConnectionStateChange() to react to connection changes
❌ Don't:
- Expose derived values as state tools
- Use
optional() for fields
- Create one tool per atomic variable (group related state)
- Expose entire large state as single tool (split or expand)
- Mix Zod schemas and TypeScript type definitions in the same file
- Create overly granular file structures (one atom per file, one tool per file)
- Import
Bridge from @mcp-web/bridge (use MCPWebBridge)
Additional Resources
- For complete examples, see examples.md
- For API details: