The quickest way to learn what an MCP server owes its users is to be one first. The reference server that the MCP maintainers publish to exercise every protocol feature is a good place to start: it is safe to run, and it shows exactly why you read a server before you trust it.
Pin the version
An MCP server's tool descriptions are instructions that a model reads. Start a server with npx some-server and no version, and every start fetches whatever was published last, so a later release can change what those instructions say without you noticing. Pin an exact version, and a change reaches you only when you choose to upgrade and read what changed.
List what it offers
The MCP Inspector, the protocol's own debugging client, has a command-line mode that connects, sends one request and prints the answer. Asked for tools/list, the reference server returns fourteen tools, each with a description, an input schema and four annotations: readOnlyHint, destructiveHint, idempotentHint and openWorldHint.
Two of them deserve a second look.
| Tool | Annotation | What it actually does |
|---|---|---|
get-env |
readOnlyHint: true |
Returns every environment variable, including the DEMO_API_KEY set for the test |
gzip-file-as-resource |
openWorldHint: true |
Fetches whatever URL it is given, from the machine the server runs on |
get-env really does change nothing, so its annotation is honest. Read-only describes side effects, not confidentiality. Annotations are also claims a server makes about itself, and the specification tells clients to treat them as untrusted unless the server itself is trusted.
Engineering Insight
gzip-file-as-resourceis not marked destructive, yet a model, or text an attacker planted somewhere the model reads, can give it any URL: a router's admin page, a service on localhost, or a cloud metadata endpoint such as169.254.169.254. The response lands in the model's context. That is server-side request forgery, andopenWorldHint: trueis the only clue in the annotations.
Allow only what you decided
In Claude Code, add the server and then deny the tools you do not want a model to have, in the project's .claude/settings.local.json:
{ "permissions": { "deny": ["mcp__everything__get-env", "mcp__everything__gzip-file-as-resource"] } }
A denied MCP tool is removed from the model's context entirely, not merely refused when called. In Codex, list the only tools the server may expose with enabled_tools. Of the two, the allow list is safer when a server can grow, because a tool added in a later version stays off until you choose to enable it.
A stdio server runs as you, with your files, your network and your environment variables. Installing one is running a program, not adding a bookmark.
Originally published at engineertoai.com. The full lesson is free, with no sign-up: Use a server before you build one.

Top comments (4)
The core point as I read it: a stdio MCP server is just a program running with your permissions, so you audit it for what it can reach, not only for what it claims to do. The annotations are self-reported, which is why the openWorldHint on a fetch tool matters more than the destructive flag staying false. One caveat on the deny list: it hides tools by name, so a server that renames a tool in a later release slips past it. Do you pin by lockfile hash as well as version, so a republished tag cannot change under you?
A deny list by name has the rename problem; an allow list per caller, kept on the server, does not. In the MCP sandbox I run,
policy.jsongrants each agent identity its tools, the identity comes from a ServiceAccount token checked by TokenReview, and anything not granted comes back asUnknown tool, the same answer as a tool that does not exist.A renamed or newly added tool reaches no agent until someone writes it into the grant. The decision moves from every client's settings file to one reviewed file.
The threat model states the limit plainly: the server decides who may call a tool, not what a granted tool does. A granted fetch tool pointed at
169.254.169.254still needs a wall in the network.Keeping the grant on the server and answering Unknown tool for anything not granted is the part I would copy, because it also stops an agent from probing which tools exist. Your last point is the one people skip: the allow list decides who calls fetch, and only an egress rule decides where fetch can go.
The get-env row is the sharpest teaching example here because it splits two senses of "safe" that the annotation vocabulary collapses. readOnlyHint certifies side effects on the world; it says nothing about information flow into the context. A tool can be side-effect-free and still be a disclosure channel - get-env is safe for the filesystem and unsafe for the DEMO_API_KEY. Reading a server before trusting it means auditing both dimensions: what it can change, and what it can disclose. The four hints only encode the first half.
The allow-list point pairs with that. A denied tool is removed from the model's context entirely, which is stronger than a runtime refusal - a tool the model cannot see cannot be talked into misuse by injected text. But deny lists age badly against a growing server: the tool you have not heard of yet is allowed by default. Pin the version, allow-list the surface, and the instruction set the model runs on changes only when you decide it does. That is the same reason the spec calls annotations untrusted claims: everything a server says about itself is content until verified, and your two-row table is exactly where content and capability diverge.