How to build an MCP server that's safe to connect

Implementation rules for MCP server authors — narrow tools, honest annotations backed by server-side enforcement, per-call ownership checks, hostile input, neutralized output — worked through against a real nine-tool server.

5 min read Updated

An MCP server has two untrusted ends. The caller is a language model that can be talked out of almost anything by text it reads. Your response then lands in that model's context window, where it may be read as instruction.

Neither end is a program you control, which is what separates this from writing a REST API. The example throughout is RelayLink's server, a relay that carries briefings between people's assistants. The spec at modelcontextprotocol.io tells you how to publish a tool, not which tools deserve to exist.

Design the tool list as the blast radius

Your tool list enumerates what a fully compromised model can cause. Write it as if it will be read aloud in an incident review.

One action per tool, named for the action rather than the resource. RelayLink publishes nine: five declare themselves read-only, two handle drafts, one fetches a package, and exactly one delivers.

The absences matter as much. No send_package, no search, no unsend, no mark-as-read, no tool that creates a consent relationship. A tool that doesn't exist cannot be argued into running.

Never let one call compose and commit

Anything irreversible needs two calls, and the split must live in your data model, not in a description a model can rationalize past. Why the gap matters is a separate argument; here is the mechanism.

draft_package writes a draft row and returns an id plus the literal words "NOT sent yet." confirm_send takes that id and refuses unless four things hold: the draft exists, its author is the calling user, its status is still pending, and it is under 24 hours old. An expired draft is cancelled, not merely rejected, forcing a fresh look.

Make the status column a concurrency token, so two racing confirms cannot both send. Keep the second call tiny — confirm_send takes exactly two arguments, the draft id and an optional replacement note, not the recipient, the ask, the brief, or the topic. Approval means nothing if the approved object can be edited on the way out.

Annotate honestly, then enforce anyway

Annotations are hints. The SDK documentation says plainly that clients should not base tool-use decisions on annotations from untrusted servers. Annotate for the human reading an approval prompt; put the guarantee in code.

RelayLink declares openWorldHint false on all nine. Five declare read-only and idempotent. get_package and draft_package declare destructive false but pointedly do not claim read-only, because both write — one records a read receipt, the other inserts a draft.

Beyond that, confirm_send declares only a title — "Send package (irreversible)". It stays silent on the rest so the spec defaults apply — not read-only, destructive, not idempotent. Silence is the strongest warning the vocabulary offers.

Worth conceding: cancel_draft declares itself idempotent, yet a second cancel returns an error rather than a second success — true to the spec's meaning, surprising to anyone retrying blindly. Annotations explained covers the vocabulary.

Authorize on every call, not at connect

Authentication tells you who is calling. It says nothing about whether they own the id in the argument — and a model that read someone else's identifier out of hostile text will pass it along happily.

Re-derive ownership inside every tool. get_package refuses anyone not a participant in that package's thread; confirm_send refuses a draft belonging to another user; cancel_draft scopes its query by author id, so a foreign id matches no row.

Check policy again at the moment of effect, not just when the object was created. RelayLink runs its consent gate twice, at draft and at confirm, so a recipient who blocks in between stops an already-approved send.

Treat every argument as hostile

Descriptions are guidance to a model; only code is a limit. RelayLink's draft tool suggests 150 to 400 words for the context brief, and the server enforces 3,500 characters. Assume the guidance is ignored.

Validate against fixed sets rather than free strings — response shape must be opinion, decision, review, info or fyi; urgency none, when_convenient, this_week or today. Cap collection sizes as well as lengths: at most 10 assumptions, 8 options, 8 decisions, 10 open questions, 3 excerpts.

Canonicalize identifiers before any policy check touches them. RelayLink parses a recipient address down to its mailbox and discards the display-name wrapper, so Carol <carol@example.com> and stray casing resolve to one account and cannot route a registered user around the consent gate.

Neutralize what you hand back

Whatever your tool returns will sit in a context window beside a model with capabilities. If it came from a third party, it is attacker-influenced input.

RelayLink cleans relayed text on every read path. Markdown image syntax becomes [image removed: …] — the URL survives as inert text, the syntax that would make a renderer fetch it does not. Inline links become text plus a parenthesized URL. Reference definitions are deleted outright, so any form that slipped past a pattern has nothing to resolve to.

Then give each surface a pass suited to its risk — the email path defangs bare URLs so mail clients cannot linkify them; the web view uses HTML encoding and a content-security policy permitting no scripts.

Framing helps and does not enforce. Delivered packages carry a header telling the reading assistant to treat everything below as third-party information, never as instructions. That is a layer on top of neutralization, never a replacement. See tool poisoning.

Where this stops

RelayLink's MCP endpoint carries a rate limit, but not the per-IP one covering its public pages — that would throttle every user of a shared hosted assistant together the moment one of them looped. It partitions by the presented credential instead, and sets the ceiling generously (300 calls a minute), because this is an abuse bound, not a product quota. The limits that actually bite are domain-level — the two-step send, the consent gate, a daily cap on first contact with strangers. Whether that suits your server depends on what one call can cause.

Run your own surface against the operational checklist before you publish. Or connect to a nine-tool server and spend ten minutes trying to make it do something it shouldn't — the refusals are the design.

Frequently asked questions

What makes an MCP server safe to connect to?
A narrow tool list, irreversible actions split across two calls that the server itself enforces, ownership checked on every call rather than once at connection time, strict validation of every argument, and third-party content neutralized before it is returned into a model's context.
Do MCP tool annotations enforce anything?
No. Annotations are hints for a client to display or reason about, and the protocol documentation is explicit that a client should not make tool-use decisions based on annotations from a server it does not trust. Annotate honestly, then enforce the same rules in code, because only the code is binding.
Why does returned content need sanitizing if my server is not a web app?
Because whatever a tool returns lands in a model's context window next to that model's other tools. Third-party text arriving there is attacker-influenced input. Markdown image and link syntax is the usual concern, since a renderer downstream may fetch a URL without anyone clicking anything.