mcp-web
// 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 onlytypes.ts- TypeScript types derived from schemas usingz.infer<>
// types.ts - derive types from schemas, never define types manually
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:
// Svelte runes example
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'; // ← Class is MCPWeb (not MCPWebBridge)
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();
// Expose state - returns [getter, setter, cleanup]
mcpWeb.addStateTools({
name: 'todos',
description: 'List of all todo items',
get: () => store.get(todosAtom),
set: (value) => store.set(todosAtom, value),
schema: TodosSchema,
expand: true, // For collections
});
// Add actions for complex operations
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, // Contains arrays/records
get: () => store.app,
set: (value) => { store.app = value; },
expand: true, // Generates add/set/delete tools for collections
});
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(), // User-visible
});
Key Design Rules
Schema Design
- Size: 5-20 properties per setter tool
- Depth: Keep schemas flat
- Collections: Use
expand: truefor 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):
// ❌ Ambiguous
z.object({ description: z.string().optional() })
// ✅ Clear
z.object({ description: z.string().nullable().default(null) })
Connection State
Monitor connection status to handle reconnection or display UI feedback.
Core Library
// Check current state
mcpWeb.connected // boolean - fully connected and authenticated
mcpWeb.connecting // boolean - during connection/authentication handshake
// Subscribe to changes (returns unsubscribe function)
const unsubscribe = mcpWeb.onConnectionStateChange(() => {
console.log('Connection:', mcpWeb.connected);
console.log('Connecting:', mcpWeb.connecting);
});
// Cleanup on unmount
unsubscribe();
React Integration
const { isConnected, isConnecting } = useMCPWeb();
// Show loading indicator during connection
if (isConnecting) return <Spinner />;
// Show offline warning when disconnected
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', // Must be unique per auth token
});
Key rules:
sessionNameis optional — unnamed sessions work as before- Names must be unique per auth token — the bridge rejects duplicates with a clean
authentication-failedmessage, andconnect()rejects with anError - 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):
// game-names.ts — localStorage-based slot allocator
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));
}
},
};
}
// mcp-tools.ts
const { name, release } = claimGameName();
export const releaseGameName = release;
export const mcpWeb = new MCPWeb({
...MCP_WEB_CONFIG,
sessionName: name,
});
// App.svelte (or equivalent lifecycle)
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(usesnew 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'; // ← Class is MCPWebBridge
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: truefor growing collections - Mark system fields with
system() - Use
nullable()instead ofoptional() - Keep schemas in
schemas.ts, types intypes.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
Bridgefrom@mcp-web/bridge(useMCPWebBridge)
Additional Resources
- For complete examples, see examples.md
- For API details:
@mcp-web/core: api-reference-core.md@mcp-web/bridge: api-reference-bridge.md@mcp-web/client: api-reference-client.md@mcp-web/integrations: api-reference-integrations.md@mcp-web/tools: api-reference-tools.md@mcp-web/decompose-zod-schema: api-reference-decompose-zod-schema.md