Rebasely Beta: data may be purged

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.
d0ed32fa38d363cfdedb5b829e01b51daf08b1f7
Parent 8a55442 Browse filesHistory

Changed files

8 files changed208 additions7 deletionsagainst 8a55442

  1. Changedsrc/server.ts+9−0
  2. Addedsrc/usage.ts+43−0
  3. Changedsrc/worker.ts+19−4
  4. Addedtest/fakes.ts+44−0
  5. Changedtest/server.test.ts+10−0
  6. Addedtest/usage.test.ts+38−0
  7. Changedtest/worker.test.ts+41−2
  8. Changedwrangler.jsonc+4−1
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.
View file
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;
+}
View file
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 {
View file
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> };
+}
View file
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" } });
View file
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");
+});
View file
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 () => {
View file
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" }]
}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.