Rebasely Beta: data may be purged

examples/MCP-tools Public

Write down how the tools are designed and why

Three concepts (tool design, the HTTP transport, usage counts), the three decisions behind them, and guides for adding a tool and connecting a client. The concepts list the code they describe, so kb check flags them when that code changes without them.
2d86268248d29109c66385ab152f291337304e92
Parent e74616c Browse filesHistory

Changed files

9 files changed324 additions0 deletionsagainst e74616c

  1. Added.rebasely/kb/concepts/http-transport.md+37−0
  2. Added.rebasely/kb/concepts/tool-design.md+48−0
  3. Added.rebasely/kb/concepts/usage-counts.md+30−0
  4. Added.rebasely/kb/decisions/0001-stateless-streamable-http.md+25−0
  5. Added.rebasely/kb/decisions/0002-tool-errors-are-results.md+30−0
  6. Added.rebasely/kb/decisions/0003-usage-counts-in-kv.md+22−0
  7. Added.rebasely/kb/guides/adding-a-tool.md+54−0
  8. Added.rebasely/kb/guides/connecting-a-client.md+58−0
  9. Added.rebasely/kb/index.md+20−0
Added.rebasely/kb/concepts/http-transport.md+37−0
@@ -0,0 +1,37 @@
+---
+id: http-transport
+type: concept
+owner: justin
+component: mcp-server
+describes:
+ - src/index.ts
+ - src/worker.ts
+reviewed: 2026-10-13
+---
+# The HTTP transport
+
+`src/index.ts` is the Worker entry and only points `fetch` at `handle` in `src/worker.ts`. Keeping the handler out of the entry file means the tests import it under plain Node.
+
+## Routes
+
+| Route | What it does |
+| --- | --- |
+| `POST /mcp` | One JSON-RPC message in, one JSON response out. |
+| `OPTIONS /mcp` | CORS preflight, 204. |
+| `GET`, `DELETE /mcp` | 405: there are no sessions and no standalone SSE stream. |
+| `GET /stats` | Usage counts; see [[usage-counts]]. |
+| `GET /` | A one-line note saying where the endpoint is. |
+
+## A request
+
+For each `POST /mcp`, `serveMcp` builds a new `McpServer` with `createServer`, connects it to a new `WebStandardStreamableHTTPServerTransport` with `sessionIdGenerator: undefined` and `enableJsonResponse: true`, and returns whatever `handleRequest` produces. The transport checks the `Accept` header (clients must accept both `application/json` and `text/event-stream`), parses the body, and the response is plain JSON rather than an SSE stream. Nothing survives the request, so any isolate in any location can answer the next one. [[0001-stateless-streamable-http]] has the reasoning.
+
+A stateless transport cannot be reused: the SDK throws if `handleRequest` is called twice on one. Building a server per request is cheap (three tool registrations), so we do not try to pool them.
+
+## CORS
+
+Every `/mcp` and `/stats` response carries `access-control-allow-origin: *` and allows the MCP headers (`mcp-protocol-version`, `mcp-session-id`, `last-event-id`). The server has no cookies or credentials, so an open origin costs nothing and lets the MCP Inspector and web agents connect directly.
+
+## Usage counting
+
+`createServer` takes an `onCall` hook. The Worker hands it a function that passes the KV write to `ctx.waitUntil`, so the count happens after the response has gone and a KV error is logged rather than returned.
View file
Added.rebasely/kb/concepts/tool-design.md+48−0
@@ -0,0 +1,48 @@
+---
+id: tool-design
+type: concept
+owner: justin
+component: tools
+describes:
+ - src/tools/**
+ - src/server.ts
+reviewed: 2026-10-13
+---
+# Tool design
+
+A tool is one module in `src/tools/` that exports the result of `defineTool` (`src/tools/types.ts`). It has no idea MCP exists: `src/server.ts` registers every tool in `TOOLS` with the SDK's `McpServer`, and that is the only place the two meet.
+
+## What a tool has
+
+- **name**: snake_case, a verb and an object where one fits (`convert_units`, `text_stats`). Models see the name first, and in a long tool list it is often all they skim.
+- **title**: a short human label for client UIs.
+- **description**: written for the model, not for us. It says what the tool does, when to reach for it ("whenever the user needs an exact figure"), and the limits that matter (units must share a quantity, US customary volumes). If a model would otherwise guess, the description tells it not to.
+- **input**: a zod object. Every field has a `.describe()` with an example value. Defaults live in the schema (`words_per_minute` defaults to 238), so they show up in `tools/list` and the tool never sees a missing value.
+- **output**: a zod object too. The SDK publishes it as `outputSchema` and checks every result against it before it leaves the server, so a tool cannot drift from its contract quietly.
+- **run**: a pure function from parsed input and a `ToolContext` (just `now` today) to output. No I/O, no clock reads, no globals that change. That is what makes the unit tests one line each.
+
+## Lenient in, strict out
+
+Inputs accept what a model is likely to send: `pounds`, `°F` and `Kilometres` all resolve in `convert_units`; `tokyo` resolves to `Asia/Tokyo` in `time_in_zone`. Outputs are always canonical: the unit symbol, the IANA name, ISO 8601 in UTC. A model that echoes the output back as input gets the same answer.
+
+We stop being lenient where guessing would be wrong. `time_in_zone` refuses an instant without an offset, because the Worker would read it in its own zone, and refuses a city that matches more than one zone.
+
+## Results
+
+A successful call returns `structuredContent` and the same object as JSON in a text block, as the spec recommends for clients that do not read structured content yet. Numbers are rounded to twelve significant digits so float noise (`0.30000000000000004`) never reaches the model.
+
+## Failures
+
+A tool throws `ToolError` (`src/tools/errors.ts`) for anything the caller can fix. The server turns it into a result with `isError: true` and the message as text; see [[0002-tool-errors-are-results]]. Write the message as advice: what was wrong, then what would work.
+
+```
+Unknown time zone "Mars/Olympus_Mons". Use an IANA name such as Europe/Paris, Asia/Tokyo or America/New_York.
+```
+
+Arguments that fail the input schema never reach `run`; the SDK answers them with an `isError` result too. Any other exception is a bug: the server logs it and tells the model that retrying the same input will not help.
+
+## Annotations
+
+Every tool is registered with `readOnlyHint`, `idempotentHint` and `openWorldHint: false`. Clients may use these to skip a confirmation prompt. If a tool ever writes something or calls out to the network, it must not inherit them; change `register` in `src/server.ts` to take them from the tool.
+
+To add one, follow [[adding-a-tool]].
View file
Added.rebasely/kb/concepts/usage-counts.md+30−0
@@ -0,0 +1,30 @@
+---
+id: usage-counts
+type: concept
+owner: justin
+component: usage
+describes:
+ - src/usage.ts
+reviewed: 2026-10-13
+---
+# Usage counts
+
+`src/usage.ts` keeps one KV key per tool per UTC day in the `USAGE` namespace:
+
+```
+calls:2026-10-10:convert_units -> { "calls": 41, "errors": 3 }
+```
+
+A call counts when it reached the tool, whether the tool succeeded or returned a `ToolError`. A call rejected by the input schema does not count, and neither does `tools/list`.
+
+## Writes
+
+`countCall` reads the key, adds one, and writes it back with the counts as both the value and the key's metadata, and a 90 day expiry. It runs inside `ctx.waitUntil` (see [[http-transport]]), so it never slows a response.
+
+## Reads
+
+`GET /stats?days=N` (1 to 31, default 7) calls `readUsage`, which lists each day's prefix and takes the counts from the metadata that `list()` already returns. A day costs one list call, not one get per tool. Days with no calls appear with an empty `tools` object, so the response always has N entries.
+
+## How far to trust it
+
+The counts are close, not exact. Two calls to the same tool in the same moment can both read 41 and both write 42. KV is also eventually consistent: a write can take up to a minute to show in other locations. That is enough to see which tools models use and which ones fail often, and it is the trade [[0003-usage-counts-in-kv]] makes.
View file
Added.rebasely/kb/decisions/0001-stateless-streamable-http.md+25−0
@@ -0,0 +1,25 @@
+---
+id: 0001-stateless-streamable-http
+type: decision
+owner: justin
+reviewed: 2026-10-13
+status: accepted
+---
+# 0001 Stateless Streamable HTTP
+
+## Context
+
+MCP over HTTP can be stateful: the server hands out an `Mcp-Session-Id` on `initialize`, the client sends it on every later request, and a `GET` opens a long-lived SSE stream for server-to-client messages. On Workers that means pinning a session to one place, which needs a Durable Object per session (the `agents` package's `McpAgent` does this).
+
+Our tools need none of it. They do not send notifications, sample the client's model, ask for elicitation, or report progress. Every call is one request and one answer.
+
+## Decision
+
+Serve MCP statelessly with the SDK's `WebStandardStreamableHTTPServerTransport`: no session id generator, JSON responses instead of SSE, and a new `McpServer` and transport for each `POST`. `GET` and `DELETE` on `/mcp` return 405.
+
+## Consequences
+
+- Any isolate can answer any request. No Durable Object, no session storage, no session to expire or lose.
+- The only runtime dependency is `@modelcontextprotocol/sdk` (and `zod`); no `agents` package and no `nodejs_compat` flag.
+- `initialize` is a formality: nothing it negotiates is remembered. Clients are fine with that, since the spec allows servers without sessions.
+- If a tool ever needs server-to-client messages (progress on a slow call, for example), this decision has to be revisited, most likely by moving to `McpAgent` and a Durable Object per session.
View file
Added.rebasely/kb/decisions/0002-tool-errors-are-results.md+30−0
@@ -0,0 +1,30 @@
+---
+id: 0002-tool-errors-are-results
+type: decision
+owner: justin
+reviewed: 2026-10-13
+status: accepted
+---
+# 0002 Tool errors are results
+
+## Context
+
+A tool can fail in two ways in MCP. A JSON-RPC error (`-32602` and friends) tells the client the request itself was bad. A tool result with `isError: true` tells the model the call went through and here is what went wrong. Many clients show JSON-RPC errors to the user, or retry, and never put them in front of the model.
+
+Most of our failures are the model's to fix: a unit we do not know, a time zone that does not exist, an instant without an offset.
+
+## Decision
+
+Every failure inside a tool call comes back as a result with `isError: true` and a message written for the model:
+
+- A tool throws `ToolError` for anything the caller can fix. The message says what was wrong and gives an example of what would work.
+- Arguments that fail the input schema are answered by the SDK the same way, with zod's message.
+- Any other exception is logged and returned as an `isError` result saying the server failed and the same input will fail again, so the model does not loop.
+
+JSON-RPC errors are left for protocol problems: malformed JSON, an unknown method, a missing `Accept` header.
+
+## Consequences
+
+- Models correct themselves on the next call (`furlong` becomes `ft`, `Mars/Base` becomes `UTC`) instead of giving up.
+- Error messages are part of the interface and are tested like output. Changing one is a behavior change.
+- Usage counts can report errors per tool, which shows where descriptions need work; see [[usage-counts]].
View file
Added.rebasely/kb/decisions/0003-usage-counts-in-kv.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0003-usage-counts-in-kv
+type: decision
+owner: justin
+reviewed: 2026-10-13
+status: accepted
+---
+# 0003 Usage counts in KV
+
+## Context
+
+We want to see which tools get called and how often they fail, per day. Exact counting on Workers means a single writer: a Durable Object, or D1. Either adds a component and a hop on every call. KV is already the cheapest storage to bind and read, but it has no atomic increment and writes take time to spread.
+
+## Decision
+
+Count in a KV namespace bound as `USAGE`, one key per tool per UTC day, with a read then a write inside `ctx.waitUntil`. Store the counts in the key's metadata so `/stats` reads a day with one `list()`. Keys expire after 90 days.
+
+## Consequences
+
+- No extra hop on the request path, and a KV failure cannot fail a tool call.
+- Counts can undercount when the same tool is called many times in the same second. We accept that: the numbers are for spotting trends, not for billing.
+- Anything that has to be exact or enforced, such as a per-client limit, needs a single writer: a Durable Object, not this namespace. If counting itself ever needs that, this decision gets superseded.
View file
Added.rebasely/kb/guides/adding-a-tool.md+54−0
@@ -0,0 +1,54 @@
+---
+id: adding-a-tool
+type: guide
+owner: justin
+component: tools
+describes:
+ - src/tools/**
+reviewed: 2026-10-13
+---
+# Adding a tool
+
+Read [[tool-design]] first; this is the checklist.
+
+## 1. Write the module
+
+Create `src/tools/<name-in-kebab>.ts` and export a `defineTool(...)`:
+
+```ts
+import { z } from "zod";
+import { ToolError } from "./errors.ts";
+import { defineTool } from "./types.ts";
+
+// toRoman is the usual greedy walk over M, CM, D, ... I; left out here.
+export const romanNumeral = defineTool({
+ name: "roman_numeral",
+ title: "Roman numerals",
+ description: "Write a whole number from 1 to 3999 as a Roman numeral. Use it instead of converting by hand.",
+ input: z.object({ value: z.number().int().describe("The number to convert, such as 2026.") }),
+ output: z.object({ value: z.number().int(), numeral: z.string() }),
+ run({ value }) {
+ if (value < 1 || value > 3999) throw new ToolError(`${value} is out of range. Roman numerals cover 1 to 3999.`);
+ return { value, numeral: toRoman(value) };
+ },
+});
+```
+
+- Keep `run` pure. If it needs the time, take it from the context: `run(input, { now })`.
+- Put limits in the schema when the model can see them there (`.int()`, `.max()`), and throw `ToolError` for the rest.
+- Describe every input field with an example value.
+
+## 2. Register it
+
+Add it to `TOOLS` in `src/server.ts`. The order there is the order of `tools/list`. Update the server's `instructions` string if the summary of what the server does has changed.
+
+## 3. Test it
+
+- `test/<name>.test.ts`: call `run` directly. Cover a normal case, an edge case and every `ToolError` message.
+- `test/server.test.ts`: add the name to the `tools/list` assertion, and add a call through the client if the tool has anything unusual (a default, a context value).
+
+Run `npm test` and `npm run typecheck`.
+
+## 4. Document it
+
+Add the tool to the README with an example call and result. If it changes how tools work in general, update [[tool-design]] in the same change.
View file
Added.rebasely/kb/guides/connecting-a-client.md+58−0
@@ -0,0 +1,58 @@
+---
+id: connecting-a-client
+type: guide
+owner: justin
+reviewed: 2026-10-13
+---
+# Connecting a client
+
+The endpoint is `https://<your worker>.workers.dev/mcp` once deployed, or `http://localhost:8787/mcp` under `npm run dev`. It speaks Streamable HTTP without sessions (see [[0001-stateless-streamable-http]]) and needs no auth.
+
+## MCP Inspector
+
+```sh
+npx @modelcontextprotocol/inspector
+```
+
+Pick the **Streamable HTTP** transport, enter the URL and connect. Direct connections work because `/mcp` allows any origin. The Tools tab lists all three tools; run `time_in_zone` with `zone` set to `tokyo` to check the round trip.
+
+## Claude Desktop
+
+Claude Desktop launches local servers over stdio, so bridge to the HTTP endpoint with `mcp-remote` in `claude_desktop_config.json`:
+
+```json
+{
+ "mcpServers": {
+ "mcp-tools": {
+ "command": "npx",
+ "args": ["-y", "mcp-remote", "http://localhost:8787/mcp"]
+ }
+ }
+}
+```
+
+Restart Claude Desktop and the tools appear under the tools menu. On plans that support custom connectors you can instead add the deployed URL directly as a remote connector.
+
+## Claude Code
+
+```sh
+claude mcp add --transport http mcp-tools http://localhost:8787/mcp
+```
+
+## curl
+
+Every message is a POST with both content types in `Accept`:
+
+```sh
+curl -s http://localhost:8787/mcp \
+ -H 'content-type: application/json' \
+ -H 'accept: application/json, text/event-stream' \
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
+```
+
+## When it does not work
+
+- **406 Not Acceptable**: the client did not send both `application/json` and `text/event-stream` in `Accept`.
+- **415 Unsupported Media Type**: the body was not sent as `application/json`.
+- **405 on GET**: the client is trying to open a standalone SSE stream or resume a session. Pick a Streamable HTTP client mode that does not need one; older SSE-only clients are not supported.
+- **Tools missing**: check `GET /` answers, then run `tools/list` with curl to see what the server sends.
View file
Added.rebasely/kb/index.md+20−0
@@ -0,0 +1,20 @@
+# mcp-tools knowledge base
+
+mcp-tools is a small MCP server on Cloudflare Workers. Three read-only tools (`convert_units`, `time_in_zone`, `text_stats`) answer from their input alone; the Worker serves them over Streamable HTTP at `/mcp` and counts calls in KV.
+
+## How it works
+
+- [[tool-design]]: what every tool has (name, description for the model, schemas, structured output) and how failures reach the model.
+- [[http-transport]]: how a POST to `/mcp` becomes an MCP response, and what the Worker does around it.
+- [[usage-counts]]: what `/stats` counts, where, and how far to trust it.
+
+## Why it is this way
+
+- [[0001-stateless-streamable-http]]: a fresh server per request, no sessions.
+- [[0002-tool-errors-are-results]]: failures come back as tool results the model can read.
+- [[0003-usage-counts-in-kv]]: approximate counters in KV rather than a Durable Object.
+
+## How to
+
+- [[adding-a-tool]]: from a new file in `src/tools/` to a passing test.
+- [[connecting-a-client]]: Claude Desktop, the MCP Inspector, or curl.
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.