examples/MCP-tools Public
Start an MCP server with a unit conversion tool
Models are unreliable at unit arithmetic, so the first tool does it for them. Tools are plain modules with zod schemas and a pure run function; src/server.ts registers them with the MCP SDK and turns ToolError into an isError result the model can read.
Changed files
13 files changed384 additions0 deletionsin the first commit
- Added.gitignore+3−0
- AddedLICENSE+21−0
- AddedREADME.md+8−0
- Addedpackage-lock.json
- Addedpackage.json+25−0
- Addedsrc/server.ts+57−0
- Addedsrc/tools/convert-units.ts+108−0
- Addedsrc/tools/errors.ts+7−0
- Addedsrc/tools/types.ts+25−0
- Addedtest/connect.ts+19−0
- Addedtest/convert-units.test.ts+49−0
- Addedtest/server.test.ts+46−0
- Addedtsconfig.json+16−0
Added.gitignore+3−0
| @@ -0,0 +1,3 @@ | ||
| +node_modules/ | ||
| +.wrangler/ | ||
| +.dev.vars | ||
AddedLICENSE+21−0
| @@ -0,0 +1,21 @@ | ||
| +MIT License | ||
| + | ||
| +Copyright (c) 2026 Justin Torre | ||
| + | ||
| +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| +of this software and associated documentation files (the "Software"), to deal | ||
| +in the Software without restriction, including without limitation the rights | ||
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| +copies of the Software, and to permit persons to whom the Software is | ||
| +furnished to do so, subject to the following conditions: | ||
| + | ||
| +The above copyright notice and this permission notice shall be included in all | ||
| +copies or substantial portions of the Software. | ||
| + | ||
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| +SOFTWARE. | ||
AddedREADME.md+8−0
| @@ -0,0 +1,8 @@ | ||
| +# mcp-tools | ||
| + | ||
| +A small MCP server with exact, read-only helpers for models. It starts with `convert_units`. | ||
| + | ||
| +```sh | ||
| +npm install | ||
| +npm test | ||
| +``` | ||
Addedpackage-lock.json
This change is too large to show here (more than 1,000 changed lines). View the file or the raw file.
Addedpackage.json+25−0
| @@ -0,0 +1,25 @@ | ||
| +{ | ||
| + "name": "mcp-tools", | ||
| + "version": "0.1.0", | ||
| + "private": true, | ||
| + "description": "A small MCP server on Cloudflare Workers with unit conversion, time zone and text statistics tools.", | ||
| + "license": "MIT", | ||
| + "type": "module", | ||
| + "engines": { | ||
| + "node": ">=24" | ||
| + }, | ||
| + "scripts": { | ||
| + "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"", | ||
| + "typecheck": "tsc", | ||
| + "dev": "npx wrangler dev" | ||
| + }, | ||
| + "dependencies": { | ||
| + "@modelcontextprotocol/sdk": "^1.32.0", | ||
| + "zod": "^4.6.5" | ||
| + }, | ||
| + "devDependencies": { | ||
| + "@cloudflare/workers-types": "^5.20261016.1", | ||
| + "@types/node": "^24.19.2", | ||
| + "typescript": "^7.0.2" | ||
| + } | ||
| +} | ||
Addedsrc/server.ts+57−0
| @@ -0,0 +1,57 @@ | ||
| +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||
| +import { convertUnits } from "./tools/convert-units.ts"; | ||
| +import { ToolError } from "./tools/errors.ts"; | ||
| +import type { AnyTool } from "./tools/types.ts"; | ||
| + | ||
| +export const SERVER_INFO = { name: "mcp-tools", version: "0.1.0" }; | ||
| + | ||
| +// Every tool the server offers, in the order tools/list returns them. | ||
| +export const TOOLS: AnyTool[] = [convertUnits]; | ||
| + | ||
| +export type ServerOptions = { | ||
| + // The clock tools read; tests pin it. | ||
| + now?: () => Date; | ||
| +}; | ||
| + | ||
| +// Builds an MCP server with every tool registered. The server holds no state between requests, | ||
| +// so a caller can make one per request, and each test makes its own. | ||
| +export function createServer(options: ServerOptions = {}): McpServer { | ||
| + const server = new McpServer(SERVER_INFO, { | ||
| + instructions: "Small, exact helpers: unit conversion. Each tool is read-only and answers from its input alone.", | ||
| + }); | ||
| + for (const tool of TOOLS) register(server, tool, options); | ||
| + return server; | ||
| +} | ||
| + | ||
| +function register(server: McpServer, tool: AnyTool, options: ServerOptions): void { | ||
| + server.registerTool( | ||
| + tool.name, | ||
| + { | ||
| + title: tool.title, | ||
| + description: tool.description, | ||
| + inputSchema: tool.input, | ||
| + outputSchema: tool.output, | ||
| + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, | ||
| + }, | ||
| + (args) => call(tool, args, options), | ||
| + ); | ||
| +} | ||
| + | ||
| +// The SDK has already checked `args` against the input schema; a mismatch never gets here and | ||
| +// comes back to the model as an isError result, the same as a ToolError. | ||
| +function call(tool: AnyTool, args: Record<string, unknown>, options: ServerOptions): CallToolResult { | ||
| + try { | ||
| + const result = tool.run(args, { now: options.now?.() ?? new Date() }); | ||
| + // Structured output for clients that read it, and the same JSON as text for those that don't. | ||
| + return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result }; | ||
| + } catch (err) { | ||
| + if (err instanceof ToolError) return failure(err.message); | ||
| + console.error(`${tool.name} failed`, err); | ||
| + return failure(`${tool.name} failed on the server. The input looked valid, so retrying with the same input will fail the same way.`); | ||
| + } | ||
| +} | ||
| + | ||
| +function failure(text: string): CallToolResult { | ||
| + return { isError: true, content: [{ type: "text", text }] }; | ||
| +} | ||
Addedsrc/tools/convert-units.ts+108−0
| @@ -0,0 +1,108 @@ | ||
| +import { z } from "zod"; | ||
| +import { ToolError } from "./errors.ts"; | ||
| +import { defineTool } from "./types.ts"; | ||
| + | ||
| +const QUANTITIES = ["length", "mass", "volume", "temperature"] as const; | ||
| +type Quantity = (typeof QUANTITIES)[number]; | ||
| + | ||
| +// Each unit converts to and from its quantity's base unit: metres, kilograms, litres, kelvin. | ||
| +type Unit = { | ||
| + symbol: string; | ||
| + quantity: Quantity; | ||
| + toBase: (v: number) => number; | ||
| + fromBase: (v: number) => number; | ||
| +}; | ||
| + | ||
| +function linear(quantity: Quantity, symbol: string, factor: number): Unit { | ||
| + return { symbol, quantity, toBase: (v) => v * factor, fromBase: (v) => v / factor }; | ||
| +} | ||
| + | ||
| +// Imperial volumes are US customary; the description says so. | ||
| +const UNITS: Unit[] = [ | ||
| + linear("length", "mm", 0.001), | ||
| + linear("length", "cm", 0.01), | ||
| + linear("length", "m", 1), | ||
| + linear("length", "km", 1000), | ||
| + linear("length", "in", 0.0254), | ||
| + linear("length", "ft", 0.3048), | ||
| + linear("length", "yd", 0.9144), | ||
| + linear("length", "mi", 1609.344), | ||
| + linear("length", "nmi", 1852), | ||
| + linear("mass", "mg", 1e-6), | ||
| + linear("mass", "g", 0.001), | ||
| + linear("mass", "kg", 1), | ||
| + linear("mass", "t", 1000), | ||
| + linear("mass", "oz", 0.028349523125), | ||
| + linear("mass", "lb", 0.45359237), | ||
| + linear("mass", "st", 6.35029318), | ||
| + linear("volume", "ml", 0.001), | ||
| + linear("volume", "l", 1), | ||
| + linear("volume", "m3", 1000), | ||
| + linear("volume", "tsp", 0.00492892159375), | ||
| + linear("volume", "tbsp", 0.01478676478125), | ||
| + linear("volume", "fl-oz", 0.0295735295625), | ||
| + linear("volume", "cup", 0.2365882365), | ||
| + linear("volume", "pt", 0.473176473), | ||
| + linear("volume", "qt", 0.946352946), | ||
| + linear("volume", "gal", 3.785411784), | ||
| + { symbol: "C", quantity: "temperature", toBase: (v) => v + 273.15, fromBase: (v) => v - 273.15 }, | ||
| + { symbol: "F", quantity: "temperature", toBase: (v) => ((v - 32) * 5) / 9 + 273.15, fromBase: (v) => ((v - 273.15) * 9) / 5 + 32 }, | ||
| + { symbol: "K", quantity: "temperature", toBase: (v) => v, fromBase: (v) => v }, | ||
| +]; | ||
| + | ||
| +const BY_KEY = new Map(UNITS.map((u) => [u.symbol.toLowerCase(), u])); | ||
| + | ||
| +export function findUnit(text: string): Unit | undefined { | ||
| + return BY_KEY.get(text.trim().toLowerCase()); | ||
| +} | ||
| + | ||
| +function unitOrThrow(text: string): Unit { | ||
| + const unit = findUnit(text); | ||
| + if (!unit) throw new ToolError(`Unknown unit "${text}". Known units, by quantity: ${knownUnits()}.`); | ||
| + return unit; | ||
| +} | ||
| + | ||
| +function knownUnits(): string { | ||
| + return QUANTITIES.map((q) => `${q}: ${UNITS.filter((u) => u.quantity === q).map((u) => u.symbol).join(", ")}`).join("; "); | ||
| +} | ||
| + | ||
| +// Twelve significant digits hides float noise (0.30000000000000004) without losing anything a | ||
| +// person would read. | ||
| +function tidy(v: number): number { | ||
| + return Number(v.toPrecision(12)); | ||
| +} | ||
| + | ||
| +export const convertUnits = defineTool({ | ||
| + name: "convert_units", | ||
| + title: "Convert units", | ||
| + description: | ||
| + "Convert a value between units of length, mass, volume or temperature. " + | ||
| + "Use it instead of doing the arithmetic yourself whenever the user needs an exact figure. " + | ||
| + "Units are short symbols: length mm, cm, m, km, in, ft, yd, mi, nmi; mass mg, g, kg, t, oz, lb, st; " + | ||
| + "volume ml, l, m3, tsp, tbsp, fl-oz, cup, pt, qt, gal (US customary); temperature C, F, K. " + | ||
| + "Both units must measure the same quantity.", | ||
| + input: z.object({ | ||
| + value: z.number().describe("The number to convert, in the `from` unit."), | ||
| + from: z.string().min(1).describe("The unit the value is in, for example km or F."), | ||
| + to: z.string().min(1).describe("The unit to convert to, for example mi or C."), | ||
| + }), | ||
| + output: z.object({ | ||
| + quantity: z.enum(QUANTITIES), | ||
| + value: z.number(), | ||
| + from: z.string(), | ||
| + result: z.number(), | ||
| + to: z.string(), | ||
| + }), | ||
| + run({ value, from, to }) { | ||
| + const a = unitOrThrow(from); | ||
| + const b = unitOrThrow(to); | ||
| + if (a.quantity !== b.quantity) { | ||
| + throw new ToolError(`Cannot convert ${a.symbol} (${a.quantity}) to ${b.symbol} (${b.quantity}). Pick two units of the same quantity.`); | ||
| + } | ||
| + const base = a.toBase(value); | ||
| + if (a.quantity === "temperature" && base < 0) { | ||
| + throw new ToolError(`${value} ${a.symbol} is below absolute zero (${tidy(a.fromBase(0))} ${a.symbol}).`); | ||
| + } | ||
| + return { quantity: a.quantity, value, from: a.symbol, result: tidy(b.fromBase(base)), to: b.symbol }; | ||
| + }, | ||
| +}); | ||
Addedsrc/tools/errors.ts+7−0
| @@ -0,0 +1,7 @@ | ||
| +// A ToolError is a failure the caller can fix: a unit we don't know, a time zone that doesn't | ||
| +// exist, an instant without an offset. The server hands its message back to the model as a tool | ||
| +// result with isError set, so write it as advice: say what was wrong and what would work. | ||
| +// Anything else that throws out of a tool is a bug in this repository. | ||
| +export class ToolError extends Error { | ||
| + name = "ToolError"; | ||
| +} | ||
Addedsrc/tools/types.ts+25−0
| @@ -0,0 +1,25 @@ | ||
| +import type { z } from "zod"; | ||
| + | ||
| +// What a tool gets besides its input. `now` is passed in rather than read from the clock so | ||
| +// tests can pin it. | ||
| +export type ToolContext = { now: Date }; | ||
| + | ||
| +// One tool: its MCP metadata, its schemas and a pure function from parsed input to output. | ||
| +// The server registers it (src/server.ts); nothing here knows about MCP or HTTP. | ||
| +export type Tool<I extends z.ZodObject, O extends z.ZodObject> = { | ||
| + name: string; | ||
| + title: string; | ||
| + // Read by the model when it decides whether and how to call the tool. | ||
| + description: string; | ||
| + input: I; | ||
| + output: O; | ||
| + run(input: z.output<I>, ctx: ToolContext): z.input<O>; | ||
| +}; | ||
| + | ||
| +// Any tool, for code that handles them all alike. `run` is a method, so a specific tool is | ||
| +// assignable here. | ||
| +export type AnyTool = Tool<z.ZodObject, z.ZodObject>; | ||
| + | ||
| +export function defineTool<I extends z.ZodObject, O extends z.ZodObject>(tool: Tool<I, O>): Tool<I, O> { | ||
| + return tool; | ||
| +} | ||
Addedtest/connect.ts+19−0
| @@ -0,0 +1,19 @@ | ||
| +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | ||
| +import { createServer, type ServerOptions } from "../src/server.ts"; | ||
| + | ||
| +// A client connected to a fresh server over an in-memory transport: the whole MCP exchange, | ||
| +// without HTTP. | ||
| +export async function connect(options: ServerOptions = {}): Promise<Client> { | ||
| + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); | ||
| + await createServer(options).connect(serverSide); | ||
| + const client = new Client({ name: "test", version: "0.0.0" }); | ||
| + await client.connect(clientSide); | ||
| + return client; | ||
| +} | ||
| + | ||
| +// The text of a tool result's first content block. | ||
| +export function textOf(result: object): string { | ||
| + const [first] = ("content" in result ? result.content : []) as { type: string; text?: string }[]; | ||
| + return first?.type === "text" ? (first.text ?? "") : ""; | ||
| +} | ||
Addedtest/convert-units.test.ts+49−0
| @@ -0,0 +1,49 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { convertUnits } from "../src/tools/convert-units.ts"; | ||
| +import { ToolError } from "../src/tools/errors.ts"; | ||
| + | ||
| +const now = new Date("2026-10-03T12:00:00Z"); | ||
| +const convert = (value: number, from: string, to: string) => convertUnits.run({ value, from, to }, { now }); | ||
| + | ||
| +test("converts length, mass and volume through the base unit", () => { | ||
| + assert.equal(convert(10, "km", "mi").result, 6.21371192237); | ||
| + assert.equal(convert(1, "mi", "ft").result, 5280); | ||
| + assert.equal(convert(1, "lb", "g").result, 453.59237); | ||
| + assert.equal(convert(1, "gal", "l").result, 3.785411784); | ||
| + assert.equal(convert(3, "tsp", "tbsp").result, 1); | ||
| +}); | ||
| + | ||
| +test("converts temperatures with their offsets", () => { | ||
| + assert.equal(convert(100, "C", "F").result, 212); | ||
| + assert.equal(convert(-40, "F", "C").result, -40); | ||
| + assert.equal(convert(0, "K", "C").result, -273.15); | ||
| + assert.equal(convert(98.6, "F", "K").result, 310.15); | ||
| +}); | ||
| + | ||
| +test("reports the quantity and the canonical symbols", () => { | ||
| + assert.deepEqual(convert(2.5, "KM", " m "), { quantity: "length", value: 2.5, from: "km", result: 2500, to: "m" }); | ||
| +}); | ||
| + | ||
| +test("hides floating point noise", () => { | ||
| + assert.equal(convert(0.1, "m", "cm").result, 10); | ||
| + assert.equal(convert(0.3, "l", "ml").result, 300); | ||
| +}); | ||
| + | ||
| +test("an unknown unit lists the units it knows", () => { | ||
| + assert.throws(() => convert(1, "furlong", "m"), (err: unknown) => { | ||
| + assert.ok(err instanceof ToolError); | ||
| + assert.match(err.message, /Unknown unit "furlong"/); | ||
| + assert.match(err.message, /length: mm, cm, m, km/); | ||
| + return true; | ||
| + }); | ||
| +}); | ||
| + | ||
| +test("refuses to convert between quantities", () => { | ||
| + assert.throws(() => convert(1, "kg", "m"), /Cannot convert kg \(mass\) to m \(length\)/); | ||
| +}); | ||
| + | ||
| +test("refuses temperatures below absolute zero", () => { | ||
| + assert.throws(() => convert(-500, "F", "C"), /below absolute zero \(-459\.67 F\)/); | ||
| + assert.equal(convert(-273.15, "C", "K").result, 0); | ||
| +}); | ||
Addedtest/server.test.ts+46−0
| @@ -0,0 +1,46 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { connect, textOf } from "./connect.ts"; | ||
| + | ||
| +test("initialize reports the server and its tools capability", async () => { | ||
| + const client = await connect(); | ||
| + assert.equal(client.getServerVersion()?.name, "mcp-tools"); | ||
| + assert.ok(client.getServerCapabilities()?.tools); | ||
| + await client.close(); | ||
| +}); | ||
| + | ||
| +test("tools/list returns each tool with its schemas and read-only hint", async () => { | ||
| + const client = await connect(); | ||
| + const { tools } = await client.listTools(); | ||
| + assert.deepEqual(tools.map((t) => t.name), ["convert_units"]); | ||
| + const [convert] = tools; | ||
| + assert.deepEqual(convert.inputSchema.required, ["value", "from", "to"]); | ||
| + assert.ok(convert.outputSchema?.properties?.result); | ||
| + assert.equal(convert.annotations?.readOnlyHint, true); | ||
| + await client.close(); | ||
| +}); | ||
| + | ||
| +test("tools/call returns structured content and the same JSON as text", async () => { | ||
| + const client = await connect(); | ||
| + const result = await client.callTool({ name: "convert_units", arguments: { value: 5, from: "km", to: "m" } }); | ||
| + assert.equal(result.isError, undefined); | ||
| + assert.deepEqual(result.structuredContent, { quantity: "length", value: 5, from: "km", result: 5000, to: "m" }); | ||
| + assert.deepEqual(JSON.parse(textOf(result)), result.structuredContent); | ||
| + await client.close(); | ||
| +}); | ||
| + | ||
| +test("a ToolError comes back as an isError result, not a protocol error", async () => { | ||
| + const client = await connect(); | ||
| + const result = await client.callTool({ name: "convert_units", arguments: { value: 1, from: "kg", to: "m" } }); | ||
| + assert.equal(result.isError, true); | ||
| + assert.match(textOf(result), /Cannot convert kg \(mass\) to m \(length\)/); | ||
| + await client.close(); | ||
| +}); | ||
| + | ||
| +test("arguments that fail the input schema come back as an isError result", async () => { | ||
| + const client = await connect(); | ||
| + const result = await client.callTool({ name: "convert_units", arguments: { value: "ten", from: "km", to: "m" } }); | ||
| + assert.equal(result.isError, true); | ||
| + assert.match(textOf(result), /Invalid arguments for tool convert_units/); | ||
| + await client.close(); | ||
| +}); | ||
Addedtsconfig.json+16−0
| @@ -0,0 +1,16 @@ | ||
| +{ | ||
| + "compilerOptions": { | ||
| + "target": "es2023", | ||
| + "module": "esnext", | ||
| + "moduleResolution": "bundler", | ||
| + "lib": ["es2023"], | ||
| + "types": ["@cloudflare/workers-types", "node"], | ||
| + "strict": true, | ||
| + "noEmit": true, | ||
| + "allowImportingTsExtensions": true, | ||
| + "verbatimModuleSyntax": true, | ||
| + "erasableSyntaxOnly": true, | ||
| + "skipLibCheck": true | ||
| + }, | ||
| + "include": ["src", "test"] | ||
| +} | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.