Topic: mcp server access control

MCP server access control — who can call your tools and what they can do

The MCP protocol does not mandate caller authentication. A tool handler receives a JSON-encoded argument object from the host client and executes. By default, any process that can reach the server — any LLM call in the session, any plugin in the same agent loop — can invoke any tool with any argument. This absence of a built-in access control layer is the most common source of Permissions-axis findings in the SkillAudit corpus. The fixes exist and are straightforward; they simply require the author to explicitly model who the callers are and what they should be allowed to do.

Why MCP servers lack access control by default

MCP servers that run over stdio are launched as child processes of a single trusted client (Claude Code, Cursor, etc.). The implicit model is: if you can start the server, you're trusted. This works for personal-use servers where the only caller is the developer's own agent session. It breaks down in three scenarios:

  1. Multi-tenant deployments. A team running a shared MCP server on HTTP transport where multiple users' agent sessions can make requests. Tool handlers need to know which user is calling and enforce per-user authorization.
  2. Multi-tool agent loops. An agent that has multiple MCP servers registered can instruct any of them to call any of the others indirectly by passing their output as arguments. This creates cross-tool privilege chaining that no individual server sees as an access control issue.
  3. Prompt-injection attacks. A malicious document fetched by one tool provides instructions that the LLM follows, resulting in calls to other tools the user did not intend. Without caller identity gates, the server can't distinguish "user instructed this" from "injected instruction".

Pattern 1: missing caller identity gate on sensitive tools

The most common Permissions HIGH in the corpus is a tool that modifies persistent state — writes to a file, calls a destructive API, modifies a database record — with no check on who the caller is.

// Vulnerable: no caller gate on destructive operation
server.registerTool("delete_record", async ({ id }) => {
  await db.records.delete({ where: { id } });
  return { deleted: id };
});

For HTTP-transport servers, the fix is a middleware layer that reads a caller identity token from the request context and validates it before the tool handler runs:

// Fixed: extract caller identity from transport context
server.registerTool("delete_record", async ({ id }, context) => {
  const caller = context.transport?.headers?.["x-mcp-caller-id"];
  if (!caller) throw new Error("caller identity required");

  const record = await db.records.findUnique({ where: { id } });
  if (record.ownerId !== caller) throw new Error("not authorized");

  await db.records.delete({ where: { id } });
  return { deleted: id };
});

For stdio servers used in personal or single-user contexts, the risk is lower but not zero — prompt injection can still invoke tools the user didn't intend. A confirmation-required pattern (return a "about to delete X — confirm?" response and check for an explicit confirmation argument on the next call) adds a meaningful speed bump against injected deletions.

Pattern 2: ambient authority — tools inherit the server process's full permissions

MCP servers run as processes. Unless explicitly restricted, they inherit the full filesystem access, network access, and environment variables of the parent process. A tool that is designed to read a single config file can — through a path traversal bug or a prompt-injection attack — read any file the process can access.

The Permissions axis checks for this via two signals:

The fix is to declare explicit constraints in both the mcp_config.json and the tool handler itself:

// Fixed: constrain path to allowed prefix
import path from "path";

const ALLOWED_BASE = path.resolve("./workspace");

server.registerTool("read_file", async ({ filepath }) => {
  const resolved = path.resolve(ALLOWED_BASE, filepath);
  if (!resolved.startsWith(ALLOWED_BASE)) {
    throw new Error("path outside allowed workspace");
  }
  return { content: await fs.readFile(resolved, "utf8") };
});

Pattern 3: cross-tool privilege chaining

This pattern is not caught by static analysis in SkillAudit's current version — it requires dynamic analysis or manual review. But it's worth understanding because it's a structural access control issue unique to MCP architectures.

Consider a server with two tools: fetch_url (reads a web page) and write_file (writes to disk). Neither tool is individually dangerous if both have appropriate constraints. But an LLM agent with both tools registered can be prompted to: fetch a URL that returns malicious content → use that content as the argument to write_file → overwrite a critical file. The MCP server sees two valid tool calls; the combined effect was not authorized.

Mitigations at the server level:

What SkillAudit checks on the Permissions axis

The Permissions axis in every SkillAudit report covers:

See the Methodology page for the full Permissions axis scoring rules. For the most common access control fix — path constraint patterns — the MCP server security checklist has the grep command to find unconstrained path arguments in your repo.

Check your server's Permissions score

Paste your GitHub URL to see the full Permissions axis breakdown — which scopes are over-declared, which tools are missing caller gates, and the specific file paths to fix.

Run a free audit