The minimum security checklist for shipping an MCP server

An MCP server is not an API. It is an API whose caller is a language model that will do what a document told it to. That changes what "secure" means.

Before you start

Two framings before any of the specifics.

Your tools will be called by something that was persuaded. Not maliciously compromised, persuaded. An agent reads a GitHub issue, a web page, a PDF, a log line, and that text becomes part of its context. Design as though every tool call might originate from the least trustworthy document your users touch.

Your server is one of several. Whoever installs it will also install five others, sharing one context window and one set of decisions. Capabilities you think of as isolated will compose with capabilities you have never seen. The MCP specification's security guidance and the OWASP Agentic Security Initiative both start here.

1. Least privilege

The credential your server holds is the ceiling on everything else. No amount of careful tool design matters if the token behind it is an org owner.

  • Request the narrowest scope that makes the server useful, then check whether it is still too wide.
  • Separate read and write into different credentials where the platform allows.
  • Prefer short-lived, refreshable credentials over long-lived ones.
  • If your server needs admin scope for one tool, ship that tool separately.

Concretely: a GitHub MCP server that exists to read issues does not need repo. It needs public_repo or a fine-grained token with issues read access, and the difference is the entire blast radius.

2. HTTPS

Remote MCP servers must use HTTPS. This is not a nice-to-have.

Everything crossing that connection is sensitive: the bearer token in the Authorization header, the arguments to every tool call, and every tool result. That last one is the underrated part: an attacker on the path who can rewrite tool results has a direct prompt-injection channel into the agent. They do not need to steal anything; they just need to answer.

http://localhost during development is fine. http://anything-else is not. GATE003 checks this.

3. Authentication

If your server exposes anything non-public, it needs authentication, and the answer should be OAuth 2.1 as described in the MCP authorization specification rather than a bearer token you invented.

Two requirements that are easy to miss and load-bearing:

Validate the audience. Your server must reject tokens that were not issued for it. A token minted for a different service and replayed at yours is the classic confused-deputy setup.

Never pass a token through. When your server calls an upstream API, it must not forward the token it received from the client. Use token exchange or your own credential. Passing through breaks the audience restriction you just implemented.

More detail in MCP authentication.

4. Token handling

  • Short-lived access tokens, per RFC 9700.
  • Never in a URL. Query strings end up in access logs, proxy logs, Referer headers and browser history. GATE004 exists because this keeps happening.
  • Never in command-line arguments. ps is readable by every user on the machine. GATE002.
  • Never logged, not even truncated.
  • Rotatable without redeploying, so that rotation actually happens.

5. Credential storage

For the credentials your server holds:

  • The OS keychain, a secret manager, or an environment variable injected at runtime. In that order of preference.
  • Never a file in the repository. GATE001.
  • Never a default value in code that happens to work in development.

For the credentials your users will hold to talk to you: publish configuration examples that use ${env:YOUR_TOKEN}, never a literal. People copy examples verbatim, and your README is where their .mcp.json comes from.

6. Dependency pinning

Document an installation that pins a version:

{
  "mcpServers": {
    "yours": {
      "command": "npx",
      "args": ["-y", "@you/[email protected]"]
    }
  }
}

An unpinned npx invocation resolves and executes whatever the registry serves at agent start, with no lockfile and no review, inside a process the user has already handed credentials to. A compromised maintainer account takes effect on the next restart. GATE007.

Never document a curl … | sh installation. GATE021.

7. Destructive tools

For every tool that deletes, drops, revokes or overwrites, ask three questions:

  1. Does this need to exist? A surprising number of destructive tools were added for symmetry with a create tool and are never used.
  2. Can it be soft? A delete that marks a row and a delete that removes it are the same tool to an agent and different incidents to a user.
  3. Is it separable? If deletion lives on its own server with its own credential, users who do not need it can decline it.

If the tool must exist and must be hard, say so unmistakably in the description, and understand that the description is advice to a model, not a control. GATE010.

Human approval is the control everything else quietly assumes. Design so that it is workable rather than something users switch off:

  • Make tool names and arguments legible. execute("DELETE FROM users") is reviewable. run(payload) is not.
  • Keep the number of approvals low by keeping tools coarse where they are safe and fine where they are not. Approval fatigue is a security failure, and it is caused by design.
  • Never document autoApprove or --yolo as the recommended setup. GATE019.

9. Tool trust

Your tool descriptions are read by a model deciding what is safe to call. Two consequences.

Write them accurately. A description that says "read-only helper" on a tool that writes is either stale or a lie, and both get the same treatment from a scanner. GATE016.

Do not treat a description as a boundary. If your server aggregates or proxies other servers, their descriptions arrive as untrusted text and must never influence what your server does. The "tool poisoning" class of attack is exactly this: a description crafted to make a dangerous tool look harmless, or to instruct the model directly.

Pin and verify tool schemas at install time where you can, and alert on description drift after first approval.

10. Secrets in tool output

Whatever a tool returns goes into the model's context window. From that moment it is in the conversation, in whatever logs record the conversation, in whatever telemetry the framework sends, and available to be included in any subsequent tool call, including one that talks to the outside world.

So:

  • Redact credentials, tokens and keys out of tool results before returning them. Yes, including in error messages. Especially in error messages.
  • Do not return whole config files, whole environment dumps, or whole rows containing credential columns.
  • If a tool's honest job is to return a secret, that server should not also have any way to send data outside the system. GATE013, GATE015.

11. Logging

Log enough to investigate an incident, and nothing that becomes one:

  • Do log: which tool, when, by which client, with what outcome.
  • Do not log: full arguments (they contain secrets), full results (they contain data), or tokens in any form.
  • Treat your logs as a place an attacker would like to reach, because if your agent can read them, that is now a data source in its context window.

12. Blast radius

Finally, look at the whole thing at once. Not tool by tool. As a set.

  • Can this server both read something private and send something outward? That is the lethal trifecta in one package.
  • Can it execute arbitrary code and anything else? Then the "anything else" is decoration.
  • Would a user reasonably expect the combination you are shipping? A GitHub server that also reads the filesystem is two servers wearing a trenchcoat.
npx @usegate/cli scan

The checklist

  • Credentials are the narrowest scope that works
  • Remote transport is HTTPS, always
  • Authentication follows the MCP authorization spec; audience is validated
  • Tokens are short-lived, never in URLs, args or logs
  • No credential is passed through to an upstream API
  • Credentials come from a keychain, secret manager or injected environment
  • Documented installation pins a version
  • Every destructive tool is justified, soft where possible, separable
  • Tool names and arguments are legible enough to approve
  • Descriptions are accurate, and never treated as a control
  • Tool output is redacted; errors especially
  • Logs contain no arguments, results or tokens
  • The capability set has been reviewed as a set
  • gate scan runs in CI on the example configuration you publish

Was this page helpful?