examples/MCP-tools Public
Count tool calls per day in KV and serve them at /stats
We want to know which tools models actually call and how often they get an error back. The server reports each call that reached a tool, and the Worker counts it in the USAGE namespace through waitUntil, after the response, so a KV failure never fails a call. Counts live in key metadata, which lets GET /stats read a day with a single list.
Changed files
8 files changed208 additions7 deletionsagainst 8a55442
Changedsrc/server.ts+9−0
| @@ -14,6 +14,9 @@ | ||
| export type ServerOptions = { | ||
| // The clock tools read; tests pin it. | ||
| now?: () => Date; | ||
| + // Called once per tool call that reached the tool, after it ran. `ok` is false when it | ||
| + // returned an error result. | ||
| + onCall?: (tool: string, ok: boolean) => void; | ||
| }; | ||
| // Builds an MCP server with every tool registered. The server holds no state between requests, | ||
| @@ -43,6 +46,12 @@ | ||
| // 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 { | ||
| + const result = run(tool, args, options); | ||
| + options.onCall?.(tool.name, !result.isError); | ||
| + return result; | ||
| +} | ||
| + | ||
| +function run(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. | ||
Addedsrc/usage.ts+43−0
| @@ -0,0 +1,43 @@ | ||
| +// Calls per tool per day, in KV. One key per tool and UTC day, `calls:2026-10-10:convert_units`, | ||
| +// holding { calls, errors } both as the value and as the key's metadata, so /stats can read a | ||
| +// whole day with one list() and no gets. | ||
| +// | ||
| +// The counter is a read, then a write, so two calls that land at the same moment can count as | ||
| +// one, and KV takes about a minute to show a write everywhere. That is fine for seeing which | ||
| +// tools get used; it is not billing. | ||
| + | ||
| +export type Counts = { calls: number; errors: number }; | ||
| +export type Day = { date: string; tools: Record<string, Counts> }; | ||
| + | ||
| +const PREFIX = "calls:"; | ||
| +const KEEP_SECONDS = 90 * 24 * 60 * 60; | ||
| +export const MAX_DAYS = 31; | ||
| + | ||
| +export function dayOf(date: Date): string { | ||
| + return date.toISOString().slice(0, 10); | ||
| +} | ||
| + | ||
| +export async function countCall(kv: KVNamespace, tool: string, ok: boolean, now = new Date()): Promise<void> { | ||
| + const key = `${PREFIX}${dayOf(now)}:${tool}`; | ||
| + const prev = (await kv.get<Counts>(key, "json")) ?? { calls: 0, errors: 0 }; | ||
| + const next: Counts = { calls: prev.calls + 1, errors: prev.errors + (ok ? 0 : 1) }; | ||
| + await kv.put(key, JSON.stringify(next), { metadata: next, expirationTtl: KEEP_SECONDS }); | ||
| +} | ||
| + | ||
| +// The last `days` UTC days, newest first, including days with no calls. | ||
| +export async function readUsage(kv: KVNamespace, days: number, now = new Date()): Promise<Day[]> { | ||
| + const out: Day[] = []; | ||
| + for (let i = 0; i < days; i++) { | ||
| + const date = dayOf(new Date(now.getTime() - i * 86_400_000)); | ||
| + const prefix = `${PREFIX}${date}:`; | ||
| + const tools: Record<string, Counts> = {}; | ||
| + let cursor: string | undefined; | ||
| + do { | ||
| + const page = await kv.list<Counts>({ prefix, cursor }); | ||
| + for (const k of page.keys) if (k.metadata) tools[k.name.slice(prefix.length)] = k.metadata; | ||
| + cursor = page.list_complete ? undefined : page.cursor; | ||
| + } while (cursor); | ||
| + out.push({ date, tools }); | ||
| + } | ||
| + return out; | ||
| +} | ||
Changedsrc/worker.ts+19−4
| @@ -1,13 +1,14 @@ | ||
| import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | ||
| import { createServer } from "./server.ts"; | ||
| +import { MAX_DAYS, countCall, readUsage } from "./usage.ts"; | ||
| -export type Env = Record<string, unknown>; | ||
| +export type Env = { USAGE: KVNamespace }; | ||
| // Browser clients (the MCP Inspector in direct mode, web agents) call /mcp cross-origin. The | ||
| // server has no cookies or credentials to protect, so any origin may call it. | ||
| const CORS = { | ||
| "access-control-allow-origin": "*", | ||
| - "access-control-allow-methods": "POST, OPTIONS", | ||
| + "access-control-allow-methods": "GET, POST, OPTIONS", | ||
| "access-control-allow-headers": "content-type, accept, authorization, mcp-protocol-version, mcp-session-id, last-event-id", | ||
| "access-control-expose-headers": "mcp-session-id, mcp-protocol-version", | ||
| "access-control-max-age": "86400", | ||
| @@ -16,13 +17,14 @@ | ||
| export async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | ||
| const { pathname } = new URL(request.url); | ||
| if (pathname === "/mcp") return withCors(await serveMcp(request, env, ctx)); | ||
| + if (pathname === "/stats" && request.method === "GET") return withCors(await stats(request, env)); | ||
| if (pathname === "/" && request.method === "GET") { | ||
| return new Response("mcp-tools: an MCP server. Point a client at /mcp (Streamable HTTP).\n", { headers: { "content-type": "text/plain; charset=utf-8" } }); | ||
| } | ||
| return new Response("Not found\n", { status: 404 }); | ||
| } | ||
| -async function serveMcp(request: Request, _env: Env, _ctx: ExecutionContext): Promise<Response> { | ||
| +async function serveMcp(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | ||
| if (request.method === "OPTIONS") return new Response(null, { status: 204 }); | ||
| // Stateless: no sessions, so there is no standalone SSE stream to GET and nothing to DELETE. | ||
| if (request.method !== "POST") { | ||
| @@ -33,10 +35,23 @@ | ||
| } | ||
| // A fresh server and transport for every request. Nothing carries over, so any isolate can | ||
| // answer any request and there is no session to lose. | ||
| - const server = createServer(); | ||
| + // Counting happens after the response is sent; a KV hiccup never fails a tool call. | ||
| + const server = createServer({ | ||
| + onCall: (tool, ok) => ctx.waitUntil(countCall(env.USAGE, tool, ok).catch((err) => console.error("usage count failed", err))), | ||
| + }); | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); | ||
| await server.connect(transport); | ||
| return transport.handleRequest(request); | ||
| +} | ||
| + | ||
| +// GET /stats?days=7: calls and errors per tool for the last few UTC days, newest first. | ||
| +async function stats(request: Request, env: Env): Promise<Response> { | ||
| + const param = new URL(request.url).searchParams.get("days") ?? "7"; | ||
| + const days = Number(param); | ||
| + if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS) { | ||
| + return Response.json({ error: `days must be a whole number from 1 to ${MAX_DAYS}` }, { status: 400 }); | ||
| + } | ||
| + return Response.json({ days: await readUsage(env.USAGE, days) }, { headers: { "cache-control": "no-store" } }); | ||
| } | ||
| function withCors(response: Response): Response { | ||
Addedtest/fakes.ts+44−0
| @@ -0,0 +1,44 @@ | ||
| +// Small stand-ins for the Workers runtime, enough for the code under test. | ||
| + | ||
| +type Entry = { value: string; metadata: unknown }; | ||
| + | ||
| +// The parts of KVNamespace that src/usage.ts uses, over a Map. list() pages by `limit` so the | ||
| +// cursor loop gets exercised. | ||
| +export function fakeKv(limit = 1000): KVNamespace & { entries: Map<string, Entry> } { | ||
| + const entries = new Map<string, Entry>(); | ||
| + const kv = { | ||
| + entries, | ||
| + async get(key: string, type?: string) { | ||
| + const e = entries.get(key); | ||
| + if (!e) return null; | ||
| + return type === "json" ? JSON.parse(e.value) : e.value; | ||
| + }, | ||
| + async put(key: string, value: string, options?: { metadata?: unknown }) { | ||
| + entries.set(key, { value, metadata: options?.metadata ?? null }); | ||
| + }, | ||
| + async list(options: { prefix?: string; cursor?: string } = {}) { | ||
| + const names = [...entries.keys()].filter((k) => k.startsWith(options.prefix ?? "")).sort(); | ||
| + const start = Number(options.cursor ?? 0); | ||
| + const page = names.slice(start, start + limit); | ||
| + const done = start + limit >= names.length; | ||
| + return { | ||
| + keys: page.map((name) => ({ name, metadata: entries.get(name)!.metadata })), | ||
| + list_complete: done, | ||
| + ...(done ? {} : { cursor: String(start + limit) }), | ||
| + }; | ||
| + }, | ||
| + }; | ||
| + return kv as unknown as KVNamespace & { entries: Map<string, Entry> }; | ||
| +} | ||
| + | ||
| +// An ExecutionContext that keeps what was handed to waitUntil, so a test can await it. | ||
| +export function fakeCtx(): ExecutionContext & { settle: () => Promise<void> } { | ||
| + const pending: Promise<unknown>[] = []; | ||
| + const ctx = { | ||
| + waitUntil: (p: Promise<unknown>) => void pending.push(p), | ||
| + passThroughOnException() {}, | ||
| + props: {}, | ||
| + settle: async () => void (await Promise.all(pending.splice(0))), | ||
| + }; | ||
| + return ctx as unknown as ExecutionContext & { settle: () => Promise<void> }; | ||
| +} | ||
Changedtest/server.test.ts+10−0
| @@ -48,6 +48,16 @@ | ||
| await client.close(); | ||
| }); | ||
| +test("onCall hears about each call that reached a tool, with its outcome", async () => { | ||
| + const calls: [string, boolean][] = []; | ||
| + const client = await connect({ onCall: (tool, ok) => calls.push([tool, ok]) }); | ||
| + await client.callTool({ name: "convert_units", arguments: { value: 1, from: "m", to: "ft" } }); | ||
| + await client.callTool({ name: "time_in_zone", arguments: { zone: "Nowhere" } }); | ||
| + await client.callTool({ name: "convert_units", arguments: { value: "x", from: "m", to: "ft" } }); | ||
| + assert.deepEqual(calls, [["convert_units", true], ["time_in_zone", false]]); | ||
| + 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" } }); | ||
Addedtest/usage.test.ts+38−0
| @@ -0,0 +1,38 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { countCall, dayOf, readUsage } from "../src/usage.ts"; | ||
| +import { fakeKv } from "./fakes.ts"; | ||
| + | ||
| +const oct10 = new Date("2026-10-10T18:00:00Z"); | ||
| +const oct9 = new Date("2026-10-09T23:59:59Z"); | ||
| + | ||
| +test("counts calls and errors per tool per UTC day", async () => { | ||
| + const kv = fakeKv(); | ||
| + await countCall(kv, "convert_units", true, oct10); | ||
| + await countCall(kv, "convert_units", false, oct10); | ||
| + await countCall(kv, "text_stats", true, oct10); | ||
| + await countCall(kv, "convert_units", true, oct9); | ||
| + assert.deepEqual(await readUsage(kv, 2, oct10), [ | ||
| + { date: "2026-10-10", tools: { convert_units: { calls: 2, errors: 1 }, text_stats: { calls: 1, errors: 0 } } }, | ||
| + { date: "2026-10-09", tools: { convert_units: { calls: 1, errors: 0 } } }, | ||
| + ]); | ||
| +}); | ||
| + | ||
| +test("keeps the counts in metadata so a day is one list", async () => { | ||
| + const kv = fakeKv(); | ||
| + await countCall(kv, "time_in_zone", true, oct10); | ||
| + assert.deepEqual(kv.entries.get("calls:2026-10-10:time_in_zone")?.metadata, { calls: 1, errors: 0 }); | ||
| +}); | ||
| + | ||
| +test("reads every page of a busy day and reports empty days", async () => { | ||
| + const kv = fakeKv(2); | ||
| + for (const tool of ["a", "b", "c", "d", "e"]) await countCall(kv, tool, true, oct10); | ||
| + const [today, yesterday, before] = await readUsage(kv, 3, oct10); | ||
| + assert.deepEqual(Object.keys(today.tools), ["a", "b", "c", "d", "e"]); | ||
| + assert.deepEqual(yesterday, { date: "2026-10-09", tools: {} }); | ||
| + assert.equal(before.date, "2026-10-08"); | ||
| +}); | ||
| + | ||
| +test("days are UTC", () => { | ||
| + assert.equal(dayOf(new Date("2026-10-10T23:30:00-07:00")), "2026-10-11"); | ||
| +}); | ||
Changedtest/worker.test.ts+41−2
| @@ -1,9 +1,10 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { test } from "node:test"; | ||
| import { handle, type Env } from "../src/worker.ts"; | ||
| +import { fakeCtx, fakeKv } from "./fakes.ts"; | ||
| -const env: Env = {}; | ||
| -const ctx = { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; | ||
| +const env: Env = { USAGE: fakeKv() }; | ||
| +const ctx = fakeCtx(); | ||
| function rpc(method: string, params?: unknown, id = 1): Request { | ||
| return new Request("http://localhost/mcp", { | ||
| @@ -43,6 +44,44 @@ | ||
| assert.match(pre.headers.get("access-control-allow-headers") ?? "", /mcp-protocol-version/); | ||
| const res = await handle(rpc("tools/list"), env, ctx); | ||
| assert.equal(res.headers.get("access-control-allow-origin"), "*"); | ||
| +}); | ||
| + | ||
| +test("tool calls are counted after the response, and /stats reports them", async () => { | ||
| + const env: Env = { USAGE: fakeKv() }; | ||
| + const ctx = fakeCtx(); | ||
| + await handle(rpc("tools/call", { name: "text_stats", arguments: { text: "Hello there." } }), env, ctx); | ||
| + await handle(rpc("tools/call", { name: "time_in_zone", arguments: { zone: "Atlantis/Capital" } }), env, ctx); | ||
| + await handle(rpc("tools/list"), env, ctx); | ||
| + await ctx.settle(); | ||
| + const res = await handle(new Request("http://localhost/stats?days=1"), env, ctx); | ||
| + assert.equal(res.status, 200); | ||
| + const { days } = (await res.json()) as { days: { tools: Record<string, unknown> }[] }; | ||
| + assert.deepEqual(days[0].tools, { text_stats: { calls: 1, errors: 0 }, time_in_zone: { calls: 1, errors: 1 } }); | ||
| +}); | ||
| + | ||
| +test("a failing usage write does not fail the tool call", async () => { | ||
| + const broken = fakeKv(); | ||
| + broken.put = async () => { | ||
| + throw new Error("KV is down"); | ||
| + }; | ||
| + const ctx = fakeCtx(); | ||
| + const res = await handle(rpc("tools/call", { name: "convert_units", arguments: { value: 1, from: "m", to: "cm" } }), { USAGE: broken }, ctx); | ||
| + const body = (await res.json()) as { result: { isError?: boolean } }; | ||
| + assert.equal(body.result.isError, undefined); | ||
| + const logged = console.error; | ||
| + console.error = () => {}; | ||
| + try { | ||
| + await ctx.settle(); | ||
| + } finally { | ||
| + console.error = logged; | ||
| + } | ||
| +}); | ||
| + | ||
| +test("/stats checks its days parameter", async () => { | ||
| + for (const days of ["0", "32", "two"]) { | ||
| + const res = await handle(new Request(`http://localhost/stats?days=${days}`), env, ctx); | ||
| + assert.equal(res.status, 400); | ||
| + } | ||
| }); | ||
| test("other paths are 404", async () => { | ||
Changedwrangler.jsonc+4−1
| @@ -1,7 +1,10 @@ | ||
| // mcp-tools: the MCP server, at /mcp. Run it locally with `npm run dev`. | ||
| +// USAGE has no id: `wrangler deploy` creates the namespace on the first deploy and keeps | ||
| +// using it after that. | ||
| { | ||
| "name": "mcp-tools", | ||
| "main": "src/index.ts", | ||
| "compatibility_date": "2026-09-01", | ||
| - "observability": { "enabled": true } | ||
| + "observability": { "enabled": true }, | ||
| + "kv_namespaces": [{ "binding": "USAGE" }] | ||
| } | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.