一键导入
add-tool
// Use when the user asks to add a new MCP tool, create a tool with UI, scaffold a tool, or add functionality to the MCP App server. Also use when adding API-only tools without a UI component.
// Use when the user asks to add a new MCP tool, create a tool with UI, scaffold a tool, or add functionality to the MCP App server. Also use when adding API-only tools without a UI component.
| name | add-tool |
| description | Use when the user asks to add a new MCP tool, create a tool with UI, scaffold a tool, or add functionality to the MCP App server. Also use when adding API-only tools without a UI component. |
Each MCP tool in this project has up to 3 layers: an API tool (required), an MCP resource (if UI), and a React UI (if UI). Tools without UI only need the API layer.
Does the tool need a visual/interactive UI?
YES → Create all 3 layers (API tool + resource + web UI)
NO → Create API tool only (steps 1-2)
Always suggest adding a UI when the tool returns data that benefits from visual presentation (lists, charts, forms, structured data, interactive elements). API-only is appropriate for simple actions (toggle, delete, ping).
| Layer | File | Purpose |
|---|---|---|
| Tool definition | api/tools/<name>.ts | Zod schemas, execute logic, resource URI link |
| Tool registry | api/tools/index.ts | Add tool to exports array |
| Resource | api/resources/<name>.ts | Serve dist/client/<name>.html as MCP resource |
| Resource registry | api/main.ts | Add resource to withRuntime resources array |
| Web UI | web/tools/<name>/index.tsx | React component rendering tool states |
| Build scripts | package.json | Add TOOL=<name> to build:web and dev:web |
| Aspect | Convention | Example |
|---|---|---|
| Tool ID | snake_case | search_users |
| File names | kebab-case | search-users.ts |
| Resource URI | ui://mcp-app/<kebab> | ui://mcp-app/search-users |
| Export names | camelCase | searchUsersTool, searchUsersInputSchema |
| Build output | dist/client/<kebab>.html | dist/client/search-users.html |
Create api/tools/<name>.ts:
import { createTool } from "@decocms/runtime/tools";
import { z } from "zod";
import type { Env } from "../types/env.ts";
// Only include if tool has UI
export const MY_TOOL_RESOURCE_URI = "ui://mcp-app/my-tool";
export const myToolInputSchema = z.object({
query: z.string().describe("Search query"),
});
export type MyToolInput = z.infer<typeof myToolInputSchema>;
export const myToolOutputSchema = z.object({
results: z.array(z.string()),
});
export type MyToolOutput = z.infer<typeof myToolOutputSchema>;
export const myTool = (_env: Env) =>
createTool({
id: "my_tool",
description: "What this tool does and when to use it",
inputSchema: myToolInputSchema,
outputSchema: myToolOutputSchema,
// Only include _meta if tool has UI
_meta: { ui: { resourceUri: MY_TOOL_RESOURCE_URI } },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
execute: async ({ context }) => {
const { query } = context;
return { results: [`Result for ${query}`] };
},
});
Key points:
_meta.ui.resourceUri links tool to its UI resource (omit for API-only tools)annotations accurately — they affect how MCP clients handle the toolAdd to api/tools/index.ts:
import { myTool } from "./my-tool.ts";
export const tools = [helloTool, myTool];
Stop here for API-only tools. Continue for tools with UI.
Create api/resources/<name>.ts:
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createPublicResource } from "@decocms/runtime/tools";
import { MY_TOOL_RESOURCE_URI } from "../tools/my-tool.ts";
import type { Env } from "../types/env.ts";
const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
function getDistPath(): string {
const IS_PRODUCTION = process.env.NODE_ENV === "production";
const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../..");
return join(projectRoot, "dist", "client", "my-tool.html");
}
export const myToolAppResource = (_env: Env) =>
createPublicResource({
uri: MY_TOOL_RESOURCE_URI,
name: "My Tool UI",
description: "Interactive UI for my tool",
mimeType: RESOURCE_MIME_TYPE,
read: async () => {
const html = await readFile(getDistPath(), "utf-8");
return {
uri: MY_TOOL_RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
};
},
});
Critical: MIME type MUST be "text/html;profile=mcp-app".
In api/main.ts, import and add to the resources array:
import { myToolAppResource } from "./resources/my-tool.ts";
const runtime = withRuntime<Env, typeof StateSchema>({
configuration: { state: StateSchema },
tools,
resources: [helloAppResource, myToolAppResource],
});
Create web/tools/<name>/index.tsx:
import { useMcpState } from "@/context.tsx";
import type { MyToolInput, MyToolOutput } from "../../../api/tools/my-tool.ts";
export default function MyToolPage() {
const state = useMcpState<MyToolInput, MyToolOutput>();
if (state.status === "initializing") {
return <div>Connecting to host...</div>;
}
if (state.status === "connected") {
return <div>Waiting for tool call...</div>;
}
if (state.status === "error") {
return <div>Error: {state.error}</div>;
}
if (state.status === "tool-cancelled") {
return <div>Cancelled</div>;
}
if (state.status === "tool-input") {
return <div>Processing {JSON.stringify(state.toolInput)}...</div>;
}
// tool-result
return <div>{JSON.stringify(state.toolResult)}</div>;
}
Key patterns:
@tool/index.tsx alias)useMcpState<Input, Output>() provides typed stateinitializing, connected, tool-input, tool-result, error, tool-cancelled../../../api/tools/<name>.ts (relative path from web/tools)@/components/ui/ for consistent stylingIn package.json, add TOOL=<name> to both scripts:
{
"dev:web": "concurrently \"TOOL=hello vite build --watch\" \"TOOL=my-tool vite build --watch\"",
"build:web": "TOOL=hello vite build && TOOL=my-tool vite build"
}
| Mistake | Fix |
|---|---|
| Resource URI mismatch between tool and resource | Copy URI constant from tool file, don't redefine |
Missing .ts/.tsx in imports | Biome enforces useImportExtensions: error |
| Wrong MIME type on resource | Must be "text/html;profile=mcp-app" exactly |
Forgetting to register resource in api/main.ts | Tool works but UI never loads |
Not updating both dev:web and build:web | Dev works but production build misses the tool |
Using @tool/ imports in API code | @tool/ alias only works in web builds |
| Not handling all 6 MCP statuses | UI breaks on cancel, error, or initial states |