Type-Safe WebMCP Tools with TypeScript
Short answer: A WebMCP tool needs a JSON Schema inputSchema so agents know what arguments it takes — but if you hand-write that schema and then write execute(args), TypeScript has no idea what args actually contains. @mcptrail/webmcp-kit closes that gap: its defineTool helper infers the execute argument type directly from the JSON Schema at the type level. Declare properties.city as a string and args.city is a string inside execute — no duplicated interface, no casting, zero runtime dependencies. Optional runtime validation is one flag away.
If you’re new to the underlying API, start with What is WebMCP?. This post is about making the tools you write type-safe so the compiler catches mistakes before an agent ever calls them.
The problem: your schema and your code drift apart
Every WebMCP tool carries a JSON Schema describing its inputs. That schema is the contract the agent reads. But JSON Schema is just data — plain objects — and TypeScript can’t see through it on its own:
navigator.modelContext.registerTool({
name: "book_room",
inputSchema: {
type: "object",
properties: { city: { type: "string" }, guests: { type: "integer" } },
required: ["city"],
},
async execute(args) {
// args is `any`. args.ciy compiles fine. args.guests could be a string.
return reserve(args.city, args.guests);
},
});
Notice args.ciy — a typo that ships silently. The usual fix is to hand-write a matching interface, but now you maintain the same shape twice, and nothing forces them to stay in sync. That is exactly the kind of bug that surfaces only when an agent calls the tool in production.
defineTool: inference from the schema itself
@mcptrail/webmcp-kit treats the JSON Schema as the single source of truth. defineTool reads the schema’s properties, required, and primitive types at the type level and hands execute a fully-typed args:
npm install @mcptrail/webmcp-kit
import { defineTool } from "@mcptrail/webmcp-kit";
const bookRoom = defineTool({
name: "book_room",
description: "Reserve a hotel room in a city",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
guests: { type: "integer" },
},
required: ["city"],
},
async execute(args) {
// args.city -> string (required)
// args.guests -> number | undefined (optional integer)
args.ciy; // ❌ compile error: Property 'ciy' does not exist
return { content: [{ type: "text", text: `Booked ${args.city}` }] };
},
});
Because city is in required, args.city is a non-optional string. Because guests is not, it’s number | undefined. The mapping is:
JSON Schema type | Inferred TS type |
|---|---|
"string" | string |
"integer" / "number" | number |
"boolean" | boolean |
in required | non-optional |
not in required | T | undefined |
No interface. No cast. Rename city in the schema and every args.city reference lights up red until you fix it.
Grouping tools with toolset() and registering them
Real pages ship more than one tool. toolset() collects a batch and preserves each tool’s inferred type, and registerTools() wires the whole group into the browser’s navigator.modelContext in one call:
import { defineTool, toolset, registerTools } from "@mcptrail/webmcp-kit";
const tools = toolset([
bookRoom,
defineTool({
name: "cancel_room",
inputSchema: {
type: "object",
properties: { confirmationId: { type: "string" } },
required: ["confirmationId"],
},
async execute(args) {
return cancel(args.confirmationId); // args.confirmationId: string
},
}),
]);
const registration = registerTools(tools);
// later, when the view unmounts:
registration.unregister();
registerTools returns a handle with unregister(), so a single-page app can add and remove groups of tools as the user navigates — the agent only ever sees tools that are currently relevant.
Optional runtime validation
Type inference protects your code at compile time. But an agent can still send a payload that doesn’t match — a missing city, a string where an integer belongs. Flip on validation and webmcp-kit checks the incoming args against the schema before execute runs, rejecting bad calls with a structured error:
const bookRoom = defineTool({
name: "book_room",
inputSchema: { /* ...as above... */ },
validate: true, // reject calls that don't match the schema
async execute(args) {
// args is guaranteed to match the schema here
return { content: [{ type: "text", text: `Booked ${args.city}` }] };
},
});
You get compile-time safety for free and runtime safety when you ask for it — no separate validation library, no schema written twice. The kit is MIT-licensed and open source at github.com/ElBartoTn/webmcp-kit.
FAQ
Do I need a validation library like Zod?
No. defineTool derives the execute argument type from the JSON Schema you already have to write for WebMCP, so there’s nothing to duplicate. If you want runtime checks too, set validate: true and the kit validates against that same schema — no extra dependency. Zod still works if you prefer it, but webmcp-kit is designed so plain JSON Schema is enough.
How does it type nested objects and arrays?
The inference walks nested properties and items, so an object property becomes a typed object and an array of strings becomes string[]. Deeply nested schemas infer as deeply nested types. For anything the type mapper can’t express, you can annotate execute explicitly and still keep the schema as the runtime contract.
Does this add bundle weight to my site?
defineTool and toolset are compile-time helpers — the type inference erases entirely at build time and contributes nothing to your bundle. The only runtime code is the thin registration wrapper and the optional validator, which you pay for only when you set validate: true.
Explore features · Open MCP Trail
Related articles
- What is WebMCP? — the plain-English explainer for the browser API these tools plug into.
- Register WebMCP tools in React with useModelTool — mount-scoped registration so agents only see on-screen tools.
- How to test WebMCP tools — unit-test your
executefunctions without a live agent. - Turn a WebMCP site into a guarded MCP server — add auth, audit logs, and approvals on top.