| name | build-mcp-server |
| description | Build an MCP server with @miragon/mcp-toolkit: a host via createFrameworkApp plus a module that registers its OWN tools and a widget. Use when asked to "build/create an MCP server", "stand up an MCP server with tools and a UI", "add a module with its own tools" (createToolRegistrar), "expose a tool plus a widget", wire an AppPlugin into createFrameworkApp, add an app-only *_data feed (APP_ONLY_META), write a show_* widget tool (buildSingleWidgetView / uiMeta), or bundle a widget into mcp-app.html. The worked example is the `tasks` module. |
Build an MCP server with @miragon/mcp-toolkit
The common real case: "I'm building my own MCP server. I want it to expose some
tools and a nice UI." Your module registers its own tools, backed by data
it owns — every toolkit server is self-contained (aggregating several servers is
an external MCP gateway's job, e.g. agentgateway).
Read the worked example first — copy it, don't reinvent it:
- End-to-end module —
examples/modules/tasks/:
an AppDefinition + AppPlugin that registers list_tasks / create_task /
complete_task via createToolRegistrar, a show_tasks_board widget tool, an
app-only tasks_board_data feed, and a hand-built TasksBoard widget — wired
into the host and the app bundle. Every file is runnable as-is (no network, no DB).
- Host —
examples/host/index.ts boots the
server with createFrameworkApp.
- Concepts —
AppPlugin,
the view builder.
- For the widget itself, use the sibling skill
build-mcp-widget; for white-labeling the
client, white-label-client.
Boundaries that apply (see CLAUDE.md): ESM with .js import extensions;
server code imports mcp-use/server and @miragon/mcp-toolkit-core/tools; pure
domain logic (filters, tallies) is Vitest-tested, tool/React glue is not.
The shape of a server
host → createFrameworkApp({ plugins: [myPlugin()], app: { resourceUri, htmlPath } })
plugin → AppPlugin { definition, registerTools, registerWidgetTools }
registerTools → createToolRegistrar → list_tasks / create_task / …
registerWidgetTools → show_tasks_board (buildSingleWidgetView + uiMeta)
→ tasks_board_data (APP_ONLY_META JSON feed)
bundle → app-bundle/main.tsx maps "tasks:board" → adaptDataWidget(TasksBoard)
Step 1 — The host (createFrameworkApp)
The host is your chassis: it loads plugins and mounts the mcp-app.html bundle
as a resource. From
examples/host/index.ts:
import path from "node:path"
import { fileURLToPath } from "node:url"
import { createFrameworkApp, createFileSystemDashboardStore } from "@miragon/mcp-toolkit-core/tools"
import { createPlugin as createTasksPlugin } from "../modules/tasks/plugin.js"
const here = path.dirname(fileURLToPath(import.meta.url))
const app = await createFrameworkApp({
name: "toolkit-example-host",
version: "0.0.1",
baseUrl: process.env.MCP_URL,
plugins: [createTasksPlugin()],
app: {
resourceUri: "ui://toolkit-example/mcp-app.html",
htmlPath: path.join(here, "..", "app-bundle", "dist", "index.html"),
builder: true,
dashboardStore: createFileSystemDashboardStore({ dir: path.join(here, ".dashboards") }),
},
})
await app.listen(Number(process.env.PORT ?? 3010))
resourceUri may be omitted — the framework derives ui://<name>/mcp-app.<hash>.html
by content-hashing htmlPath.
The visual builder + dashboard CRUD (get-builder-catalogue, save/list/load/ delete-dashboard) are opt-in via app: { builder: true }; widget rendering
(render-view) always works without it.
Step 2 — The module contract (AppDefinition)
The static contract: the module name and the widgets it ships. From
examples/modules/tasks/definition.ts:
import type { AppDefinition } from "@miragon/mcp-toolkit-core"
export const definition: AppDefinition = {
name: "tasks",
steps: [],
widgets: [
{
id: "tasks:board",
description:
"A task board: KPI counts by status, filterable task list, agentic add/complete.",
requires: [],
consumes: ["tasks:board"],
size: "full",
},
],
}
Step 3 — Register the module's own tools (createToolRegistrar)
createToolRegistrar(server, client) returns a register({...}) you call once
per tool. It builds the Zod schema, advertises the outputSchema and
annotations, wraps your handler in withToolErrors for you, and mirrors the
return value into structuredContent (a bare array auto-wraps to { data: [...] }).
From examples/modules/tasks/plugin.ts:
import type { MCPServer } from "mcp-use/server"
import { z } from "zod"
import { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
function registerTaskTools(server: MCPServer, store: TaskStore) {
const register = createToolRegistrar<TaskStore>(server, store)
register({
name: "list_tasks",
description:
"List tasks, optionally filtered by status and/or priority. Returns id, title, status, priority, timestamps.",
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
status: statusSchema.optional().describe("Only tasks in this status."),
priority: prioritySchema.optional().describe("Only tasks with this priority."),
},
outputSchema: z.array(taskSchema),
handler: (client, args) => {
const { status, priority } = args as { status?: TaskStatus; priority?: TaskPriority }
return Promise.resolve(client.list({ status, priority }))
},
})
}
For the per-tool details (one entry, the annotations convention, pagination
envelopes), use the sibling skill add-mcp-tool.
Step 4 — A widget tool + an app-only data feed
Two more tools live in registerWidgetTools, which the framework hands the app's
resourceUri at boot.
(a) The widget tool computes the data now and renders it through the same
McpAppView shell as render-view. buildSingleWidgetView wraps the data in the
ViewStructuredContent envelope; uiMeta({ resourceUri }) tells the host to
render the result as UI instead of returning it:
import { buildSingleWidgetView, uiMeta } from "@miragon/mcp-toolkit-core"
import { withToolErrors } from "@miragon/mcp-toolkit-core/tools"
server.tool(
{
name: "show_tasks_board",
title: "Task Board",
description: "Show the task board; use it whenever the user wants to see their tasks.",
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
schema: z.object({}),
_meta: uiMeta({ resourceUri }),
},
withToolErrors(() => {
const board = store.board()
const view = buildSingleWidgetView({
widget: "tasks:board",
app: "tasks",
dataType: "tasks:board",
data: board,
title: "Tasks",
summary: boardSummary(board),
})
return Promise.resolve({ content: view.content, structuredContent: view.structuredContent })
}),
)
Thinner alternative: createWidgetToolRegistrar(server, client, resourceUri)
builds the uiMeta for you and takes a handler returning { text, structuredContent } —
reach for it when you assemble the structuredContent yourself instead of via
buildSingleWidgetView.
(b) The app-only feed returns the same data as plain JSON so the widget can
self-refresh via callTool. APP_ONLY_META hides it from the LLM tool surface
while keeping it callable from inside the widget iframe:
import { APP_ONLY_META } from "@miragon/mcp-toolkit-core"
server.tool(
{
name: "tasks_board_data",
title: "Task board data (internal)",
description: "Internal JSON feed (no UI) backing the board's refresh. Prefer show_tasks_board.",
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
schema: z.object({}),
_meta: APP_ONLY_META,
},
withToolErrors(() => Promise.resolve(rawData(store.board()))),
)
Step 5 — Assemble the AppPlugin
createPlugin() builds one store and closes the three registrars over it, so a
create_task is visible to the next list_tasks / show_tasks_board:
import type { AppPlugin } from "@miragon/mcp-toolkit-core"
export function createPlugin(): AppPlugin {
const store: TaskStore = createTaskStore()
return {
definition,
registerTools: (server) => registerTaskTools(server as MCPServer, store),
registerWidgetTools: (server, resourceUri) =>
registerTaskWidgetTools(server as MCPServer, store, resourceUri),
}
}
A module whose steps/widgets need typed tool calls additionally injects a typed
callTool (generated by tool-codegen) into its appConfig — see
examples/modules/articles. This skill is the plain case.
Step 6 — Bundle the widget
The widget renders inside the app bundle the host serves. Register the component
under its widget id in examples/app-bundle/main.tsx.
Because TasksBoard is a single-data ({ data }) widget, wrap it with
adaptDataWidget on the same dataType:
import { McpToolkitApp, adaptDataWidget } from "@miragon/mcp-toolkit-ui/app"
import { TasksBoard } from "../modules/tasks/widgets/TasksBoard.js"
import type { TasksBoardData } from "../modules/tasks/store.js"
const widgets = {
"tasks:board": adaptDataWidget<TasksBoardData>(TasksBoard, "tasks:board"),
}
createRoot(document.getElementById("root")!).render(<McpToolkitApp widgets={widgets} />)
Step 7 — Run & verify
cp examples/env.example examples/.env
pnpm --filter @miragon/mcp-toolkit-examples build:bundle
pnpm --filter @miragon/mcp-toolkit-examples dev:host
Drive it with the built-in inspector at http://localhost:3010/inspector, or
with curl:
curl -sX POST http://localhost:3010/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
curl -sX POST http://localhost:3010/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"show_tasks_board","arguments":{}}}' \
| jq '.result.structuredContent.context.stepData.result._dataType'
An in-process smoke test boots the plugin over a loopback socket and
round-trips the tools — copy it for your module:
examples/test/tasks.smoke.test.ts.
Before committing, the repo gates must be green:
pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm -r lint
Checklist