Tools, not copy-pasted JSON
Here is the moment the project stops being a website and starts being an operating system for your data. You say “78.4 this morning” and the row exists — no JSON, no terminal, no you in the middle.
What you are removing
Without this layer, using an assistant with your data looks like this: you describe what you want, it writes some JSON, you copy the JSON, you paste it into a terminal, you read the response back to it. You are not automating — you are being the integration, by hand, every time.
MCP — Model Context Protocol — is a small JSON-RPC convention that lets a server advertise named tools with typed arguments. The assistant discovers them, decides which one fits, and calls it. Your job shrinks to writing the tools and reading the results.
Design the tools, not the API
The single biggest mistake here is exposing your REST API as tools — get, post, query, with a table name as an argument. It works, and it is terrible: the model has to know your schema, it will invent column names, and every mistake is a write to real data.
Purpose-built tools are narrow on purpose:
| Generic — avoid | Purpose-built — prefer |
|---|---|
insert(table, payload) | log_weight(kg, when?) |
query(sql) | get_wellbeing(day?) |
update(table, id, patch) | update_meal(meal_id, items) |
A narrow tool cannot be misused into a bad write. It also gives the model a much better chance of choosing correctly, because the name says what it is for. A real set ends up looking like: log_weight, log_blood_pressure, log_meal, log_note, log_activity, get_today, get_summary, get_wellbeing, get_sleep.
The wire format, minus the ceremony
It is JSON-RPC 2.0 over a single POST endpoint. Three methods carry almost everything: initialize, tools/list, tools/call.
const TOOLS = [
{
name: "log_weight",
description: "Record a body-weight measurement in kilograms.",
inputSchema: {
type: "object",
properties: {
kg: { type: "number", description: "Weight in kg, e.g. 78.4" },
when: { type: "string", description: "ISO-8601 UTC. Defaults to now." }
},
required: ["kg"]
}
}
];
export async function onRequestPost({ request, env }) {
const rpc = await request.json();
if (rpc.method === "tools/list") {
return rpcOk(rpc.id, { tools: TOOLS });
}
if (rpc.method === "tools/call") {
const { name, arguments: args } = rpc.params;
const out = await callTool(name, args || {}, env);
return rpcOk(rpc.id, { content: [{ type: "text", text: JSON.stringify(out) }] });
}
return rpcErr(rpc.id, -32601, "Unknown method: " + rpc.method);
}
const rpcOk = (id, result) => Response.json({ jsonrpc: "2.0", id, result });
const rpcErr = (id, code, message) => Response.json({ jsonrpc: "2.0", id, error: { code, message } });
Write description fields as if for a new colleague, because that is exactly what reads them. “Weight in kg, e.g. 78.4” prevents a class of mistakes that no amount of validation will.
Validate at the tool boundary
The caller is a language model. It is helpful, fast, and entirely capable of sending "78.4 kg" as a string, or your weight in pounds, or a date in 1970. The tool is where that stops:
async function callTool(name, args, env) {
if (name === "log_weight") {
const kg = Number(args.kg);
if (!Number.isFinite(kg) || kg < 20 || kg > 400) {
return { ok: false, error: "kg must be a number between 20 and 400 — got " + JSON.stringify(args.kg) };
}
const when = args.when || new Date().toISOString();
await env.DB.prepare(
"INSERT INTO measurements (taken_utc, day, kind, value, unit, source, created_utc) " +
"VALUES (?1, substr(?1,1,10), 'weight', ?2, 'kg', 'mcp', ?3)"
).bind(when, kg, new Date().toISOString()).run();
return { ok: true, logged: { kg, when } };
}
return { ok: false, error: "Unknown tool: " + name };
}
Two things that pay off immediately. The range check turns a plausible-but-wrong write into a refusal — lesson 8.3's “fail closed”, applied to a model's enthusiasm. And the error message says what it got, which means the assistant can correct itself on the next call instead of guessing.
{ok:false, error:"…"} gives it a sentence it can act on. Reserve real error codes for real protocol problems.Log every call
Add a table — tool name, arguments, result, timestamp — and write a row on every call. On a system where an autonomous agent can write to your database, “what did it actually do last Tuesday” must be a query, not an archaeology project.
It is also the fastest debugging tool you will have: when a number looks wrong, the call log usually tells you which tool put it there and what it was handed.
Document the same surface as REST
Keep an openapi.yaml describing the same operations as ordinary HTTP. It costs an afternoon and means anything that does not speak MCP — a shortcut on your phone, a cron job, a colleague's script — can still use the system. One backend, two descriptions.