Create a WebMCP server
Want the LLM to use your own widgets autonomously? This tutorial shows you how to create a complete WebMCP server: component, recipe, registration, custom tools, and agent loop integration. By the end, your widgets will feel just as natural as the built-in ones.
Create a WebMCP server that exposes a Kanban widget, with automatic validation, custom tools, and full agent loop integration.
Prerequisites
Section titled “Prerequisites”- The boilerplate is installed (see Getting started)
- Having read Create a custom widget (recommended)
- Basic understanding of JSON Schema
What you will build
Section titled “What you will build”A WebMCP server myserver with a Kanban widget that the LLM discovers via search_recipes, reads via get_recipe, and displays via widget_display — with automatic parameter validation and a custom move_card tool.
graph LR Component[Svelte Component] --> Recipe[Recipe .md] Recipe --> Server[WebMCP Server] Server --> Layer[layer] Layer --> Agent[Agent Loop] Agent --> LLM[LLM] LLM -->|search_recipes| Server LLM -->|widget_display| Server LLM -->|move_card| ServerStep 1: Create the component
Section titled “Step 1: Create the component”The component will be rendered on the canvas when the LLM calls widget_display. Two options are available.
Option A: Svelte 5 (recommended)
Section titled “Option A: Svelte 5 (recommended)”Create src/lib/widgets/KanbanBoard.svelte:
<script lang="ts"> interface Props { title?: string; columns: { name: string; cards: { title: string; description?: string; tag?: string; }[]; }[]; }
let { title, columns }: Props = $props();</script>
{#if title} <h3>{title}</h3>{/if}
<div class="kanban"> {#each columns as col} <div class="column"> <h4>{col.name} <span class="count">({col.cards.length})</span></h4> {#each col.cards as card} <div class="card"> <strong>{card.title}</strong> {#if card.description}<p>{card.description}</p>{/if} {#if card.tag}<span class="tag">{card.tag}</span>{/if} </div> {/each} </div> {/each}</div>
<style> .kanban { display: flex; gap: 1rem; overflow-x: auto; } .column { flex: 1; min-width: 200px; background: var(--color-surface2, #1a1a2e); border-radius: 8px; padding: 0.75rem; } .card { background: var(--color-surface, #16213e); border-radius: 6px; padding: 0.5rem; margin-bottom: 0.5rem; } .tag { font-size: 0.75rem; color: var(--color-text2, #888); } .count { font-weight: normal; color: var(--color-text2, #888); font-size: 0.85em; }</style>Option B: Vanilla renderer
Section titled “Option B: Vanilla renderer”A pure function that receives an HTMLElement and data:
export function render( container: HTMLElement, data: Record<string, unknown>,): void | (() => void) { const { title, columns } = data as { title?: string; columns: { name: string; cards: { title: string }[] }[]; };
const wrapper = document.createElement('div'); wrapper.style.display = 'flex'; wrapper.style.gap = '1rem';
for (const col of columns) { const colEl = document.createElement('div'); colEl.innerHTML = `<h4>${col.name}</h4>`; for (const card of col.cards) { const cardEl = document.createElement('div'); cardEl.textContent = card.title; colEl.appendChild(cardEl); } wrapper.appendChild(colEl); }
container.appendChild(wrapper); return () => { container.innerHTML = ''; };}Step 2: Write the recipe
Section titled “Step 2: Write the recipe”Create src/lib/recipes/kanban.md:
---widget: kanbandescription: Kanban board with columns and cards. Project management, workflow, pipeline, sprint board.group: projectschema: type: object required: - columns properties: title: type: string description: Optional board title columns: type: array items: type: object required: - name - cards properties: name: type: string description: Column name cards: type: array items: type: object required: - title properties: title: type: string description: type: string tag: type: string---
## When to use
To display a column-based workflow: recruitment pipeline, sprint board,sales pipeline, or any step-based progression.
## How
Call widget_display('kanban', {columns: [{name: "To do", cards: [{title: "Task 1"}]}, {name: "In progress", cards: []}]}).
## Common mistakes
- Forgotten empty columns: always include columns even if they have no cards (cards: [])- Too many columns: beyond 5 columns, readability decreasesStep 3: Generate schemas automatically (optional)
Section titled “Step 3: Generate schemas automatically (optional)”If your component uses interface Props, auto-generate the JSON Schema:
npm run sync:schemasStep 4: Create the server
Section titled “Step 4: Create the server”import { createWebMcpServer } from '@webmcp-auto-ui/core';import KanbanBoard from './widgets/KanbanBoard.svelte';import kanbanRecipe from './recipes/kanban.md?raw';
const myserver = createWebMcpServer('myserver', { description: 'Project management widgets (kanban, gantt, ...)',});createWebMcpServer creates an empty server with a name and description:
- The name is used as the prefix in tools (
myserver_webmcp_*) - The description appears in the system prompt
Step 5: Register the widget
Section titled “Step 5: Register the widget”myserver.registerWidget(kanbanRecipe, KanbanBoard);registerWidget does three things:
- Parses the frontmatter to extract
widget,description, andschema - Stores the component as renderer
- Creates the 4 built-in tools automatically:
search_recipes,list_recipes,get_recipe,widget_display
You can register multiple widgets on the same server:
myserver.registerWidget(kanbanRecipe, KanbanBoard);myserver.registerWidget(ganttRecipe, GanttChart);Step 6: Add custom tools (optional)
Section titled “Step 6: Add custom tools (optional)”myserver.addTool({ name: 'move_card', description: 'Move a card between kanban columns.', inputSchema: { type: 'object', properties: { cardTitle: { type: 'string', description: 'Card title' }, targetColumn: { type: 'string', description: 'Target column name' }, }, required: ['cardTitle', 'targetColumn'], }, execute: async (params) => { const { cardTitle, targetColumn } = params as { cardTitle: string; targetColumn: string; }; return { ok: true, message: `Card "${cardTitle}" moved to "${targetColumn}"`, }; },});Step 7: Connect to the agent loop
Section titled “Step 7: Connect to the agent loop”import { runAgentLoop, autoui } from '@webmcp-auto-ui/agent';
const layers = [ autoui.layer(), // native widgets (stat, chart, table, ...) myserver.layer(), // your custom widgets];
const result = await runAgentLoop(userMessage, { provider, layers,});Tool prefixing is automatic:
| Raw tool | Name exposed to the LLM |
|---|---|
search_recipes | myserver_webmcp_search_recipes |
widget_display | myserver_webmcp_widget_display |
move_card | myserver_webmcp_move_card |
Don’t forget to pass the server to WidgetRenderer:
<WidgetRenderer id={block.id} type={block.type} data={block.data} servers={[myserver]}/>graph TB subgraph Layers AutoUI["autoui.layer()"] Custom["myserver.layer()"] end
subgraph "Tools exposed to LLM" A1["autoui_webmcp_widget_display"] A2["autoui_webmcp_search_recipes"] C1["myserver_webmcp_widget_display"] C2["myserver_webmcp_search_recipes"] C3["myserver_webmcp_move_card"] end
AutoUI --> A1 AutoUI --> A2 Custom --> C1 Custom --> C2 Custom --> C3Step 8: Test
Section titled “Step 8: Test”In the chat, ask something that triggers recipe search:
User: "Show me a kanban of my sprint"The LLM will follow this sequence:
sequenceDiagram participant LLM as LLM participant Agent as Agent Loop participant WS as WebMCP Server
LLM->>Agent: search_recipes("kanban sprint") Agent->>WS: search_recipes WS-->>Agent: [{name: "kanban", description: "..."}]
LLM->>Agent: get_recipe("kanban") Agent->>WS: get_recipe WS-->>Agent: {schema: {...}, instructions: "..."}
LLM->>Agent: widget_display("kanban", {columns: [...]}) Agent->>WS: validate + render WS-->>Agent: {id: "w_a3f2k1"}Checkpoint: the kanban displays with columns and cards generated by the LLM.
Parameter validation
Section titled “Parameter validation”The WebMCP server validates parameters against the JSON Schema before rendering. If validation fails, the LLM receives the expected schema and can self-correct.
Troubleshooting
Section titled “Troubleshooting”| Problem | Likely cause | Solution |
|---|---|---|
| Widget not discovered | Recipe description doesn’t match query | Enrich with synonyms |
| ”Validation failed” | LLM sends incompatible params | Add description to schema properties |
| Widget renders as raw JSON | Server not passed to WidgetRenderer | Add servers={[myserver]} |
| Custom tool not visible | Tool added after layer() | Call addTool() before layer() |
Checklist
Section titled “Checklist”- Component created (Svelte or vanilla)
- Recipe
.mdwith frontmatter (widget,description,schema) + body -
createWebMcpServer('name', {description}) -
server.registerWidget(recipe, component) -
server.addTool({...})if needed -
layers: [..., server.layer()] - Server passed to WidgetRenderer via
servers={[...]} - Test: search_recipes —> get_recipe —> widget_display
Going further
Section titled “Going further”- Combine MCP and WebMCP: your WebMCP server can coexist with remote MCP servers in the same layers
- Auto-generated schemas: use
sync:schemasto never write schemas manually - Interactive component: add the FONC bus for cross-widget interactions