Rebasely Beta: data may be purged

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.
a82d7bca6ad0ec614dee8c28e1e2f9ab858b5bb2
The first commit Browse filesHistory

Changed files

13 files changed384 additions0 deletionsin the first commit

  1. Added.gitignore+3−0
  2. AddedLICENSE+21−0
  3. AddedREADME.md+8−0
  4. Addedpackage-lock.json
  5. Addedpackage.json+25−0
  6. Addedsrc/server.ts+57−0
  7. Addedsrc/tools/convert-units.ts+108−0
  8. Addedsrc/tools/errors.ts+7−0
  9. Addedsrc/tools/types.ts+25−0
  10. Addedtest/connect.ts+19−0
  11. Addedtest/convert-units.test.ts+49−0
  12. Addedtest/server.test.ts+46−0
  13. Addedtsconfig.json+16−0
Added.gitignore+3−0
@@ -0,0 +1,3 @@
+node_modules/
+.wrangler/
+.dev.vars
View file
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.
View file
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
+```
View file
Addedpackage-lock.json

This change is too large to show here (more than 1,000 changed lines). View the file or the raw file.

View 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"
+ }
+}
View file
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 }] };
+}
View file
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 };
+ },
+});
View file
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";
+}
View file
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;
+}
View file
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 ?? "") : "";
+}
View file
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);
+});
View file
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();
+});
View file
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"]
+}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.