Skip to content

Tools

Last updated View as MarkdownAgent setup

MCP tools are functions that an MCP server exposes for clients to call. An LLM can invoke a tool to look up data, run a calculation, or call an API. The MCP server executes the tool and returns its result.

Use @modelcontextprotocol/server for a stateless createMcpHandler server. McpAgent is deprecated and feature-frozen. Existing McpAgent routes must keep using @modelcontextprotocol/sdk only while they migrate.

WebMCP example

Bridge MCP tools from a Cloudflare McpAgent into Chrome's experimental WebMCP API.

Defining tools

Use server.registerTool() to register a tool on a stateless McpServer instance. Each tool has a name, a description, an input schema defined with Zod, and a handler function.

import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

function createServer() {
	const server = new McpServer({ name: "Math", version: "1.0.0" });

	server.registerTool(
		"add",
		{
			description: "Add two numbers together",
			inputSchema: { a: z.number(), b: z.number() },
		},
		async ({ a, b }) => ({
			content: [{ type: "text", text: String(a + b) }],
		}),
	);

	return server;
}
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

function createServer() {
	const server = new McpServer({ name: "Math", version: "1.0.0" });

	server.registerTool(
		"add",
		{
			description: "Add two numbers together",
			inputSchema: { a: z.number(), b: z.number() },
		},
		async ({ a, b }) => ({
			content: [{ type: "text", text: String(a + b) }],
		}),
	);

	return server;
}

The tool handler receives the validated input and must return an object with a content array. Each content item has a type (typically "text") and the corresponding data.

Tool results

Tool results are returned as an array of content parts. The most common type is text, but you can also return images and embedded resources.

server.registerTool(
	"lookup",
	{
		description: "Look up a user by ID",
		inputSchema: { userId: z.string() },
	},
	async ({ userId }) => {
		const user = await db.getUser(userId);

		if (!user) {
			return {
				isError: true,
				content: [{ type: "text", text: `User ${userId} not found` }],
			};
		}

		return {
			content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
		};
	},
);
server.registerTool(
	"lookup",
	{
		description: "Look up a user by ID",
		inputSchema: { userId: z.string() },
	},
	async ({ userId }) => {
		const user = await db.getUser(userId);

		if (!user) {
			return {
				isError: true,
				content: [{ type: "text", text: `User ${userId} not found` }],
			};
		}

		return {
			content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
		};
	},
);

Set isError: true to signal that the tool call failed. The LLM receives the error message and can decide how to proceed.

Tool descriptions

The description parameter is critical — it is what the LLM reads to decide whether and when to call your tool. Write descriptions that are:

  • Specific about what the tool does: "Get the current weather for a city" is better than "Weather tool"
  • Clear about inputs: "Requires a city name as a string" helps the LLM format the call correctly
  • Honest about limitations: "Only supports US cities" prevents the LLM from calling it with unsupported inputs

Input validation with Zod

Tool inputs are defined as Zod schemas and validated automatically before the handler runs. Use Zod's .describe() method to give the LLM context about each parameter.

server.registerTool(
	"search",
	{
		description: "Search for documents by query",
		inputSchema: {
			query: z.string().describe("The search query"),
			limit: z
				.number()
				.min(1)
				.max(100)
				.default(10)
				.describe("Maximum number of results to return"),
			category: z
				.enum(["docs", "blog", "api"])
				.optional()
				.describe("Filter by content category"),
		},
	},
	async ({ query, limit, category }) => {
		const results = await searchIndex(query, { limit, category });
		return {
			content: [{ type: "text", text: JSON.stringify(results) }],
		};
	},
);
server.registerTool(
	"search",
	{
		description: "Search for documents by query",
		inputSchema: {
			query: z.string().describe("The search query"),
			limit: z
				.number()
				.min(1)
				.max(100)
				.default(10)
				.describe("Maximum number of results to return"),
			category: z
				.enum(["docs", "blog", "api"])
				.optional()
				.describe("Filter by content category"),
		},
	},
	async ({ query, limit, category }) => {
		const results = await searchIndex(query, { limit, category });
		return {
			content: [{ type: "text", text: JSON.stringify(results) }],
		};
	},
);

Using tools with createMcpHandler

For stateless MCP servers, define tools inside a factory function and pass the server to createMcpHandler:

import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

function createServer() {
	const server = new McpServer({ name: "My Tools", version: "1.0.0" });

	server.registerTool(
		"ping",
		{ description: "Check if the server is alive", inputSchema: {} },
		async () => ({
			content: [{ type: "text", text: "pong" }],
		}),
	);

	return server;
}

export default {
	fetch(request, env, ctx) {
		return createMcpHandler(createServer)(request, env, ctx);
	},
};
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

function createServer() {
	const server = new McpServer({ name: "My Tools", version: "1.0.0" });

	server.registerTool(
		"ping",
		{ description: "Check if the server is alive", inputSchema: {} },
		async () => ({
			content: [{ type: "text", text: "pong" }],
		}),
	);

	return server;
}

export default {
	fetch(request, env, ctx) {
		return createMcpHandler(createServer)(request, env, ctx);
	},
} satisfies ExportedHandler;

Using tools with McpAgent

This section applies only to existing legacy routes during migration. Define their tools in the init() method of an McpAgent. Tools have access to the agent instance through this, so they can read and write state.

import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export class MyMCP extends McpAgent {
	server = new McpServer({ name: "Stateful Tools", version: "1.0.0" });

	async init() {
		this.server.tool(
			"incrementCounter",
			"Increment and return a counter",
			{},
			async () => {
				const count = (this.state?.count ?? 0) + 1;
				this.setState({ count });
				return {
					content: [{ type: "text", text: `Counter: ${count}` }],
				};
			},
		);
	}
}
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export class MyMCP extends McpAgent {
	server = new McpServer({ name: "Stateful Tools", version: "1.0.0" });

	async init() {
		this.server.tool(
			"incrementCounter",
			"Increment and return a counter",
			{},
			async () => {
				const count = (this.state?.count ?? 0) + 1;
				this.setState({ count });
				return {
					content: [{ type: "text", text: `Counter: ${count}` }],
				};
			},
		);
	}
}

Next steps

Was this helpful?