An MCP server deserves boring API habits

Tool calls need the same dull safety work as normal backend endpoints.

MCP servers are dangerously easy to demo. I expose a tool, point an agent at it, ask for something mildly impressive, and a moment later the tool call appears in the console. The whole thing can be small enough that my backend brain does not quite register it as infrastructure yet. Then the demo gets access to a real account and starts changing things that somebody cares about.

An LLM may be choosing the call, but the server is still accepting structured input, talking to another service, returning structured output, and sometimes mutating state. I have spent enough time building APIs to know what happens next. The timeout I skipped becomes the timeout that hangs a run, the token from the demo becomes production auth, and the cheerful console.log(args) starts storing customer data.

The tools get sharp quickly#

A harmless first tool might look like this:

getWeather(city: string)

The list becomes more interesting once the server is useful:

refundPayment(paymentId: string, reason: string)
deleteDeployment(appId: string)
createInvoice(customerId: string, amount: number)
rotateProductionSecret(service: string)

Reading a weather result has a limited blast radius. Refunding a payment or rotating a production secret is considerably spicier. MCP already has the shape of an API protocol, with JSON-RPC messages, capabilities, cancellation, progress, logging, and errors. That is plenty of structure for me to bring back all the boring habits I use elsewhere: authentication, validation, timeouts, rate limits, audit logs, and a quick way to shut off a dangerous tool.

The first version often has one token in an environment variable, tool arguments that everybody trusts, and logs containing whatever happened to arrive. The runtime chooses the timeout by eventually getting bored. That setup is fine on my laptop. Once the tool touches shared state, I want every call to leave enough information to answer a few questions:

  • Who asked for this?
  • Which workspace did it run inside?
  • Which tool ran?
  • Was the call allowed?
  • What changed?
  • How long did it take?

If I cannot answer those after a bad call, debugging becomes vibes with timestamps. Tool descriptions can help the model choose, but a sentence such as “only use this for safe reads” is guidance, not access control. The permission check still belongs in code:

function authorizeToolCall(session, toolName, args) {
  if (!session.userId) throw new Error("missing user");
  if (!session.allowedTools.includes(toolName)) throw new Error("tool denied");
  if (args.workspaceId !== session.workspaceId) throw new Error("wrong workspace");
}

There is nothing clever in that function, which is exactly what I want. The policy survives a confused model, a creative prompt, and whatever weird afternoon the rest of the system is having.

Leave a useful and boring trail#

MCP arguments can contain secrets, source code, customer data, and random chat text somebody pasted without thinking. Dumping every argument into ordinary logs creates a second incident while I investigate the first one. I would start with metadata that lets me connect the call to a user, workspace, trace, and result:

{
  "event": "mcp_tool_call",
  "trace_id": "tr_123",
  "user_id": "u_42",
  "workspace_id": "w_7",
  "tool": "deploy.restart",
  "allowed": true,
  "duration_ms": 812,
  "result": "success"
}

Risky tools may justify a redacted argument record in a separate store with tighter access and short retention. Most dashboards do not need the raw payload. I want enough detail to reconstruct the action without turning observability into a slow data spill.

Every tool also needs a timeout that I chose deliberately:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

try {
  return await callInternalApi(args, { signal: controller.signal });
} finally {
  clearTimeout(timeout);
}

Write tools need idempotency as soon as retries are possible. The agent asks once, the network flakes after the server has done the work, the client retries, and I now have two refunds or two deployments. It sounds like an almost comical bug until the tool is connected to money, email, billing, or infrastructure.

By the time people depend on an MCP server, I want tools denied by default, input validated against a schema, logs redacted, calls bounded by timeouts, trace IDs on every hop, and a kill switch for actions with a blast radius. Those habits keep a fun demo running after the first weird request arrives and somebody depends on the result.

TRIP COMPUTER / SESSION

TRIP A

Current drive.

A private counter for this browser session. Nothing here is transmitted or retained after the session ends.

Elapsed
00:00
Sections
0
Notes
0
Screens
0

Route/

Build platebe587d2
Chassis
v7.1.3
Revision
be587d2
Last serviced
09 Sept 2026

OWNER’S MANUAL / WTHRAJAT

OPERATING NOTES

How this thing moves.

The header behaves like a small mechanical system. Its readings respond to how you move through the site.

Throttle
Scrolling is input. Faster downward movement builds more momentum and engine speed.
Transmission
Upshifts follow sustained input. Scrolling upward slows and downshifts, and may briefly show reverse.
Idle
When input stops, RPM settles near 850 with mechanical drift. The gearbox eventually returns to neutral.
Tachometer
The needle always follows the reported RPM. It is never calculated from your position on the page.
Trip A
Session time, explored sections, opened notes and approximate screens travelled stay in this tab session.

Controls

Ctrl K
Search notes
?
Open this manual
Esc
Close an instrument
Tab
Move through controls