Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
AI and AutomationDevelopmentJavaScriptNode.jsArchitecture guide

MCP security in 2026: tool approval, server trust and least privilege

How to secure Model Context Protocol servers and clients: vet and sandbox servers, pin tool definitions against rug pulls, design approvals, check token audience and keep scopes small.

Cloudsoft Zone

Updated 16 min read

Diagram of MCP trust boundaries: a host and client, a sandboxed local stdio server, a remote server checking tokens, an authorization server and a downstream API
Image: Cloudsoft Zone
In this article

An MCP server is code you let an AI model drive. Once it is connected, the model can call its tools with your files, your tokens and your customers' data, and it will read whatever text the server sends back. Securing the Model Context Protocol therefore comes down to three decisions you make on purpose: which servers you trust, which tool calls a person must approve, and how little each server is allowed to touch. This guide works through all three using the MCP specification's own security requirements and the real MCP vulnerabilities published since 2025, with tested code for the two controls most teams skip: token audience checks and tool-definition pinning.

Tested with: MCP specification revision 2026-07-28 (Authorization, Tools and Security Best Practices pages), Node.js 24.19.0, Express 5.2.1 and jose 6.2.12. The code below was run locally against locally signed ES256 tokens.

This article is specific to MCP. For prompt injection in general, including the "lethal trifecta" and patterns such as plan-then-execute, read Prompt injection defence: design AI agents that fail safely. For what changed in the stateless 2026-07-28 revision itself, see MCP 2026-07-28 explained.

Where the trust boundaries are

An MCP deployment has more parties than a normal API integration, and each one is a place where trust can be misplaced:

  • The host and its MCP client (Claude Code, an IDE, a desktop chat app or your own agent). It decides which servers are connected, which tools the model sees and which calls need a human.
  • Local servers started over stdio. They are programs running on your machine with your user's privileges.
  • Remote servers reached over Streamable HTTP. They are OAuth 2.1 resource servers and must check every token they receive.
  • The authorization server that issues tokens for a specific MCP server.
  • Downstream APIs that a server calls on the user's behalf, such as GitHub, a CRM or a database.

Across all of them runs one rule that the specification states in several places: anything a server sends back, including tool names, descriptions, annotations and results, is untrusted input. The Tools page says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers", so a readOnlyHint from an unknown server proves nothing.

What the specification actually requires

The MCP specification is unusually direct about security. These are the requirements that matter most for small teams, with their normative level:

AreaRequirementLevel
Human in the loopThere should always be a human able to deny tool invocations; clients should show which tools are exposed, indicate when tools run and ask for confirmationSHOULD
Tool inputsServers validate all tool inputs, enforce access controls, rate limit and sanitise outputsMUST
Before a callClients show tool inputs to the user before calling the server, to avoid data exfiltration; log tool usageSHOULD
Token audienceServers validate that access tokens were issued specifically for them (RFC 8707)MUST
Token passthrough"MCP servers MUST NOT accept or transit any other tokens."MUST NOT
Resource parameterClients send resource with the server's canonical URI in authorization and token requestsMUST
Proxy consentMCP proxy servers using a static client ID implement per-client consent before forwarding to the third-party authorization serverMUST
One-click local installShow the exact command, without truncation, and require explicit approval before running itMUST
State handlesPossession of a state handle is never treated as authenticationMUST NOT

Read the Security Best Practices page and the Authorization page in full before you ship a remote server. The rest of this article turns them into concrete controls.

Server trust: decide before you install

Most real MCP damage so far came from servers people installed, not from clever protocol attacks. Treat adding an MCP server like adding a dependency that runs with your credentials, because that is what it is.

Verify who publishes it

In September 2025 an npm package called postmark-mcp impersonated the email provider Postmark. According to Postmark's advisory, the package "built trust over 15 versions" before version 1.0.16 added a backdoor that blind-copied every email sent through it to an outside address. Postmark had not published an MCP server to npm at all. Check the publisher against the vendor's own documentation or GitHub organisation, pin an exact version, and review the diff when you upgrade, just as you would with any package (our checklist for vetting an open-source library applies unchanged).

Show the exact command and sandbox local servers

A local server is whatever command your client runs. The specification requires clients that offer one-click local installs to show the full command, mark it as code execution and get explicit approval. It also recommends running local servers with minimal default privileges: in a container or platform sandbox, with access only to the folders and network destinations they need. In practice:

  • Run filesystem-style servers against a project folder, never your home directory. The reference filesystem server shipped two path-validation flaws in 2025 (GHSA-q66q-fx2p-7w4m: prefix matching and symlinks), so a sandbox should still hold even when the server's own checks fail.
  • Prefer stdio for local servers. If a local server must listen on HTTP, bind it to 127.0.0.1 and require a token: the MCP Inspector's proxy accepted unauthenticated requests that could launch commands until version 0.14.1 (CVE-2025-49596).
  • Do not let a repository approve its own servers. A cloned project can ship an .mcp.json; good clients ask before using project-scoped servers. Claude Code, for example, prompts for them in interactive sessions, but its documentation notes that non-interactive runs such as claude -p and Agent SDK sessions load them without asking, so CI jobs need --strict-mcp-config or an explicit deny list.

Treat clients as attack surface too

Connecting to a hostile remote server is itself a risk. mcp-remote, a widely used bridge between local clients and remote servers, passed an attacker-controlled authorization_endpoint URL to the operating system in a way that allowed command execution (CVE-2025-6514, fixed in 0.1.16). The specification now requires clients to accept only https authorization URLs (or http on loopback in development) and never to open URLs through a shell.

Tool poisoning, shadowing and rug pulls

MCP gives the model a tool's name, description and input schema. The user usually sees a short label. That asymmetry is the root of three attacks first described by Invariant Labs in April 2025:

  • Tool poisoning: a description contains hidden instructions, for example asking the model to read ~/.ssh/id_rsa and pass it in an innocent-looking parameter.
  • Shadowing: one server's description changes how the model uses another server's tool. In Invariant's demonstration, a fake add tool redirected emails sent by a trusted send_email tool, without the malicious tool ever being called.
  • Rug pull: a server changes a tool's description after you approved it. MCP explicitly allows tool lists to change, and servers can announce it with notifications/tools/list_changed.

The general defences against injected instructions are covered in our prompt injection article. What is specific to MCP is that tool definitions are configuration you can pin. Hash each tool's definition when a person approves the server, compare on every tools/list response, and hide any tool whose hash has changed until someone reviews the difference:

JavaScript
// pin.mjs - detect changed tool definitions ("rug pulls") before the model sees them
import { createHash } from 'node:crypto';

function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}

export function fingerprint(tool) {
  const { name, title, description, inputSchema, outputSchema, annotations } = tool;
  return createHash('sha256')
    .update(canonical({ name, title, description, inputSchema, outputSchema, annotations }))
    .digest('hex');
}

// approved: Map<"serverId/toolName", sha256> saved when a human approved the server.
export function filterApprovedTools(serverId, tools, approved) {
  const allowed = [];
  const needsReview = [];
  for (const tool of tools) {
    const key = `${serverId}/${tool.name}`;
    const hash = fingerprint(tool);
    if (approved.get(key) === hash) allowed.push({ ...tool, name: `${serverId}__${tool.name}` });
    else needsReview.push({ key, hash, previous: approved.get(key) ?? null, tool });
  }
  return { allowed, needsReview };
}

Use it in your agent or gateway before tools reach the model. Two details matter. First, include the description, schemas and annotations in the hash; changing only the description is the classic rug pull. Second, prefix exposed names with a server ID you assign, not with the server's self-reported name. The specification notes that server names are not guaranteed to be unique and recommends a disambiguation strategy when aggregating tools.

Flow diagram: install with the exact command shown, review full tool descriptions, pin a SHA-256 per tool, re-check each tools/list, then expose or quarantine the tool
Pinning tool definitions turns a silent rug pull into a visible re-approval.

Approval UX that people do not click through

Approval prompts only work if people read them. The best-known MCP data leak shows what happens otherwise. In May 2025, Invariant Labs showed that a malicious issue in a public repository could make an agent using the official GitHub MCP server copy data from the user's private repositories into a public pull request. The client did ask for confirmation on each tool call, but the researchers point out that many users choose "Always allow". Nothing in the server code was wrong: the token simply reached every repository.

Design approvals in tiers so that prompts are rare enough to be taken seriously:

TierExamplesPolicy
Read inside the current scopeGet an issue, list invoices for this customerAllow, log
Write or sendCreate a pull request, send an email, update a CRM recordAsk every time, showing the real arguments
Destructive or irreversibleDelete, merge, refund, change permissionsDeny for the agent; a person does it in the product's own UI
Cross-boundaryA tool that reads private data in a session that has already read public, untrusted contentAsk, or end the session

Most hosts let you express this as configuration. In Claude Code, MCP tools appear as mcp__<server>__<tool>, rules are evaluated deny first, then ask, then allow, and allow globs are accepted only after a literal server prefix. A project .claude/settings.json for the GitHub server could look like this:

JSON
{
  "permissions": {
    "deny": [
      "mcp__github__merge_pull_request",
      "mcp__github__delete_file"
    ],
    "ask": [
      "mcp__github__create_pull_request",
      "mcp__github__create_issue"
    ],
    "allow": [
      "mcp__github__get_*",
      "mcp__github__list_*",
      "mcp__github__search_*"
    ]
  }
}

Tool names differ between server versions, so list them first and write the rules against what the server actually exposes. Whatever the host, three UX rules help: show arguments, not just the tool name (the specification recommends showing inputs before the call); never offer "always allow" for write tools; and re-ask after a tool definition changes.

Authorization for remote servers: audience, scopes and no passthrough

If you run a remote MCP server, it is an OAuth 2.1 resource server, and the specification's rules are the same ones any API should follow, with two MCP-specific traps.

Token passthrough

It is tempting to build a "thin" MCP server that takes whatever bearer token the client sends and forwards it to the real API. The specification forbids this. A server that does not check the token's audience accepts tokens minted for other services; a server that forwards them lets clients bypass its rate limits and audit trail, and makes downstream logs show the wrong caller. The server must accept only tokens issued for its own canonical URI, and must call downstream APIs with its own credential or a token obtained for that API.

The confused deputy in MCP proxies

The second trap applies when your MCP server is itself an OAuth client of a third-party API using one static client ID, while letting MCP clients register dynamically. A third-party consent cookie from an earlier, legitimate login can let an attacker's newly registered client receive an authorization code without the user seeing a consent screen. The fix in the specification is a consent page owned by the MCP server, shown per client before redirecting to the third party, naming the client, the scopes and the exact redirect URI, protected against CSRF and framing, with exact redirect URI matching and single-use state values.

Wikimedia design mockup of an OAuth consent dialog over a Wikipedia page, with a placeholder app name asking for profile access and Cancel and Allow buttons
A basic OAuth consent dialog names the app and the access; an MCP proxy's consent page must also show the scopes and the exact redirect URI.

A 2013 Wikimedia design mockup of an OAuth dialog (above) shows the gap: an app name and a vague description of access. An MCP proxy's consent page must also show the redirect URI where tokens will go.

Checking tokens and scopes in code

This Express middleware validates issuer, audience and algorithm with jose, maps each tool to one scope, and answers with the WWW-Authenticate challenges the specification expects: 401 with resource_metadata when the token is missing or invalid, and 403 with error="insufficient_scope" and the needed scope for step-up authorization.

JavaScript
// auth.mjs - resource-server checks for a remote MCP server (Express 5, jose 6)
import { createRemoteJWKSet, jwtVerify } from 'jose';

const RESOURCE = 'https://mcp.example.com/mcp';           // canonical URI of this MCP server
const ISSUER = 'https://auth.example.com';                 // the only authorization server we trust
const METADATA = 'https://mcp.example.com/.well-known/oauth-protected-resource';
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

// Least privilege: every tool names the one scope it needs.
export const TOOL_SCOPES = {
  search_invoices: 'invoices:read',
  get_invoice: 'invoices:read',
  issue_refund: 'refunds:write',
};

export function bearerAuth(verifyKey = jwks) {
  return async (req, res, next) => {
    const header = req.get('authorization') ?? '';
    const token = header.startsWith('Bearer ') ? header.slice(7) : null;
    if (!token) return challenge(res, 401);
    try {
      const { payload } = await jwtVerify(token, verifyKey, {
        issuer: ISSUER,
        audience: RESOURCE,          // reject tokens minted for any other resource
        algorithms: ['RS256', 'ES256'],
      });
      req.auth = {
        subject: payload.sub,
        clientId: payload.client_id ?? payload.azp,
        scopes: new Set(String(payload.scope ?? '').split(' ').filter(Boolean)),
      };
      return next();
    } catch {
      return challenge(res, 401, { error: 'invalid_token' });
    }
  };
}

// Call this inside each tool handler, before doing any work.
export function requireToolScope(auth, toolName, res) {
  const needed = TOOL_SCOPES[toolName];
  if (!needed) throw new Error(`No scope mapped for tool ${toolName}`); // default deny
  if (auth.scopes.has(needed)) return true;
  challenge(res, 403, { error: 'insufficient_scope', scope: needed });
  return false;
}

function challenge(res, status, extra = {}) {
  const params = [`resource_metadata="${METADATA}"`];
  for (const [k, v] of Object.entries(extra)) params.push(`${k}="${v}"`);
  res.set('WWW-Authenticate', `Bearer ${params.join(', ')}`).status(status).end();
}

Run locally, a token with no audience match returned 401, a token with only invoices:read calling issue_refund returned 403 with scope="refunds:write", and a token with both scopes succeeded. Note the default-deny line: a tool without a mapped scope throws instead of running.

Keep scopes small. The specification's scope minimisation guidance warns against publishing every scope in scopes_supported and against omnibus scopes such as files:* or admin:*, because a leaked broad token gives an attacker every tool at once. Start with a minimal read scope and ask for more through step-up challenges when a privileged tool is first used.

If you are about to put your product's API behind a remote MCP server for customers' agents, this is the part worth a second pair of eyes: an AI automation review can check token audience, scope design and approval rules before the first client connects.

Least privilege for what the server can reach

Token checks protect the front door. Least privilege limits what happens when the model is tricked into using a door you opened deliberately:

  • Narrow downstream credentials. Use a fine-grained token limited to the repositories, projects or mailboxes the task needs. Invariant's GitHub demonstration worked because one token covered public and private repositories.
  • Database roles per server. A reporting server gets a read-only database user on specific views, not the application's owner account.
  • Bind state handles to users. In the stateless 2026-07-28 revision, servers keep state in handles passed as tool arguments. The specification says to key stored state by the user ID from the verified token, for example user_id:handle, and to reject a handle presented by anyone else.
  • Egress control. A server that only needs one API should only reach that API. An egress proxy or network policy also blocks the SSRF risk the specification describes for OAuth discovery URLs, including cloud metadata addresses such as 169.254.169.254.
  • Rate limits and timeouts on every tool, which the specification requires of servers and recommends for clients.

MCP incidents and advisories worth learning from

Each item below was checked against the advisory, the NVD record or the original research.

DateWhat happenedLesson
Apr 2025Invariant Labs describes tool poisoning, shadowing and rug pullsPin and review tool definitions
May 2025GitHub MCP server used to leak private repository data via a public issueNarrow tokens; one repository per session; real approvals
Jun 2025MCP Inspector proxy without authentication, CVE-2025-49596 (fixed 0.14.1)Local HTTP servers need authentication
Jul 2025Filesystem server path bypasses, CVE-2025-53109 and CVE-2025-53110Sandbox even "restricted" servers
Jul 2025mcp-remote OS command injection from a malicious server, CVE-2025-6514 (CVSS 9.6, fixed 0.1.16)Clients must validate URLs from servers
Sep 2025Impersonating postmark-mcp package BCCs every email from version 1.0.16Verify the publisher; pin versions
Dec 2025Reference Git server: path and argument injection, CVE-2025-68143, -68144 and -68145Validate paths and reject option-like arguments
Mar 2026nginx-ui's /mcp_message endpoint skips authentication, CVE-2026-33032 (CVSS 9.8)Every MCP route needs the same auth
Sep 2026LiteLLM MCP authentication bypass, CVE-2026-59822, added to CISA's Known Exploited Vulnerabilities catalogue on 2 September 2026 (fixed 1.84.0)Invalid tokens must fail closed

The pattern is consistent: missing authentication on an MCP route, unvalidated paths and arguments, and too much trust in what a server or package claims to be. None of these needs a novel defence; they need the ordinary ones applied to a new kind of component.

Common mistakes

  • Trusting tool annotations such as readOnlyHint from servers you do not control.
  • Approving a server once and never noticing that its tool descriptions changed.
  • Giving the model one personal access token that reaches everything the developer can reach.
  • Forwarding the client's token to a downstream API, or accepting tokens without an audience check.
  • Protecting /mcp but not a second endpoint that reaches the same tools, as in the nginx-ui case.
  • Loading project-scoped servers in CI, where nobody sees the approval prompt.
  • Running local servers with the user's full home directory and network access.

When not to use MCP for a task

  • Fixed, deterministic workflows. If the steps never change, call the API directly from code or a workflow tool. A model choosing tools adds risk without adding value.
  • Irreversible actions with no human in the loop. Payments, deletions and permission changes belong in the product UI or a reviewed job, not behind a model's tool call.
  • Servers you cannot inspect or pin. If you cannot see the source, the publisher or the version, do not give it credentials.
  • Sessions that mix untrusted input with private data and an outbound channel. Split them into separate agents or sessions instead.

A short checklist

  1. Inventory every MCP server, its publisher, version and the credentials it holds.
  2. Run local servers in a sandbox with only the folders and network access they need.
  3. Pin tool definitions by hash and quarantine changes.
  4. Allow reads, ask for writes with arguments shown, deny destructive tools.
  5. On remote servers, check issuer, audience and scope on every request; never pass tokens through.
  6. Give each server its own narrow downstream credential.
  7. Log every tool call with user, client, tool, arguments summary and result status.
  8. Subscribe to advisories for the servers and clients you run, and patch quickly.

If you are also setting company-wide rules for AI tools, our AI governance checklist covers the policy side.

Cloudsoft Zone builds MCP servers and AI agents for small teams with token audience checks, one scope per tool, approval tiers and tool-call audit logs in place from the first release. See our AI and business process automation service.

Frequently asked questions

  • What is token passthrough in MCP and why is it forbidden?

    Token passthrough is when an MCP server accepts a token that was not issued for it and forwards it to a downstream API. The specification says MCP servers MUST NOT accept or transit any other tokens, because it bypasses the server's own controls, breaks audit trails and lets tokens be reused across services.

  • What is an MCP rug pull?

    A rug pull is when a server changes a tool's description or schema after a user has approved it, so the model receives new, possibly malicious instructions. Hashing each approved tool definition and hiding tools whose hash changes until someone re-approves them stops it from happening silently.

  • Can I trust readOnlyHint and other tool annotations?

    Only from servers you trust. The MCP Tools specification says clients MUST consider tool annotations untrusted unless they come from trusted servers. Use your own allow, ask and deny rules instead.

  • Should local MCP servers use stdio or HTTP?

    Prefer stdio, which limits access to the client that started the server. If a local server must use HTTP, bind it to loopback and require an authorization token; the MCP Inspector vulnerability CVE-2025-49596 came from an unauthenticated local proxy.

  • Does MCP authorization apply to stdio servers?

    No. The authorization specification applies to HTTP-based transports; stdio servers should take credentials from the environment. That makes sandboxing and narrow credentials even more important for local servers.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Security Best Practices (2026-07-28) — Model Context Protocol, 2026-07-28
  2. Authorization (2026-07-28) — Model Context Protocol, 2026-07-28
  3. Tools (2026-07-28) — Model Context Protocol, 2026-07-28
  4. MCP Security Notification: Tool Poisoning Attacks — Invariant Labs, 2025-04-01
  5. GitHub MCP Exploited: Accessing private repositories via MCP — Invariant Labs, 2025-05-26
  6. Information regarding malicious postmark-mcp package — Postmark, 2025-09-25
  7. CVE-2025-6514 (mcp-remote) — NIST NVD, 2025-07-09
  8. MCP Inspector proxy server lacks authentication (GHSA-7f8r-222p-6f5g) — GitHub Security Advisories, 2025-06-13
  9. CVE-2026-59822 (LiteLLM MCP authentication bypass) — NIST NVD, 2026-07-08
  10. Configure permissions — Anthropic (Claude Code docs)

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.