Topic: mcp server capability escalation security

MCP server capability escalation security — chained tool calls and privilege creep

An MCP server that exposes a readFile tool and a writeFile tool separately looks fine in isolation. The scanner grades each tool against its own permission scope. But if a malicious or misbehaving LLM can read a file, extract a credential, pass it to a second tool call, and then invoke a privileged action with that credential — the server has effectively been escalated from a read-only tool to an admin-level one. This page covers the three capability escalation vectors in MCP servers and the guard pattern for each.

Escalation vector 1: tool-to-tool credential flow

The most common capability escalation pattern: Tool A reads a file that contains a credential (API key, private key, password). The LLM extracts the credential from Tool A's response and passes it as an argument to Tool B. Tool B, receiving a valid credential, performs a privileged action that Tool A's permission scope would never allow directly.

From the perspective of Tool B's handler, it received a valid credential — it has no way to know whether that credential came from the user's config, from the LLM's training data, or from a prior tool call that read it from the filesystem. The handler validates the credential successfully and proceeds.

// Escalation example:
// Step 1: LLM calls readFile({ path: '.env' })
//   → returns "ADMIN_API_KEY=sk-admin-secret-abc123"
// Step 2: LLM calls callAdminAPI({ apiKey: 'sk-admin-secret-abc123', action: 'delete-all-users' })
//   → Tool B validates sk-admin-secret-abc123 as a valid key and executes

// Guard pattern: scope tool access at the tool level, not the credential level
// Each tool should only accept credentials from its designated source

function callAdminAPITool(params) {
  // WRONG: accepting apiKey from arbitrary caller input
  // const { apiKey } = params;

  // RIGHT: read the credential from a server-side store, never from caller params
  const apiKey = process.env.ADMIN_API_KEY;
  if (!apiKey) throw new Error('Admin API not configured');

  // The tool doesn't accept a credential param at all
  // This eliminates the credential-passing escalation vector entirely
  return callAdminAPI(apiKey, params.action);
}

The root fix is architectural: tools that perform privileged actions should read their credentials from environment variables or a secrets store managed by the server operator, never from caller-supplied parameters. A tool that accepts a credential as a parameter is a tool that can be escalated to through any prior tool that reads secrets from the environment.

Escalation vector 2: shared session state poisoning

MCP servers that maintain session state across tool calls (user preferences, conversation context, accumulated results) often share that state across all tools in the server. If a low-privilege tool can write to the session state, and a high-privilege tool reads from it as trusted input, a malicious write creates an escalation path.

// Vulnerable: shared session state with no namespace separation
class VulnerableServer {
  constructor() {
    this.session = {}; // ALL tools read/write here
  }

  setPreferenceTool(params) {
    // Low-privilege tool: sets user preferences
    this.session[params.key] = params.value;
    // Attack: LLM calls setPreference({ key: 'adminMode', value: true })
  }

  adminActionTool(params) {
    // High-privilege tool: reads session.adminMode as trusted
    if (!this.session.adminMode) return { isError: true, content: [{ type: 'text', text: 'Not authorized' }] };
    // ...privileged action...
  }
}

// Fixed: namespace-separated session state per trust level
class SecureServer {
  constructor() {
    this.userPrefs = {};      // writable by low-privilege tools
    this.serverState = {};    // read-only for all tools; set by server init only
    this.adminContext = {};   // writable ONLY by auth-verified admin tools
  }

  setPreferenceTool(params) {
    // Can only write to userPrefs — cannot touch serverState or adminContext
    const ALLOWED_PREF_KEYS = new Set(['theme', 'language', 'timezone']);
    if (!ALLOWED_PREF_KEYS.has(params.key)) {
      return { isError: true, content: [{ type: 'text', text: 'Unknown preference key' }] };
    }
    this.userPrefs[params.key] = params.value;
  }

  adminActionTool(params, authContext) {
    // Reads adminContext, which is set by the auth layer — not writable by tools
    if (!authContext?.isAdmin) return { isError: true, content: [{ type: 'text', text: 'Not authorized' }] };
    // ...privileged action...
  }
}

Escalation vector 3: chained permission accumulation

Some MCP servers implement progressive authorization: Tool A grants access to a resource, and Tool B uses that access grant to do more. If the grants are stored in mutable state and Tool B doesn't re-verify the original authorization context, an attacker can forge the access grant by calling Tool A with a broad resource scope that the LLM wouldn't normally request.

// Guard pattern: re-verify authorization at each privileged operation
// Don't cache and re-use access grants across tool calls

async function privilegedOperation(params, context) {
  // Re-verify auth on EVERY call — don't rely on a cached grant from a prior tool
  const authResult = await verifyAuth(context.token, {
    resource: params.resource,
    action: params.action,
  });

  if (!authResult.allowed) {
    return {
      content: [{ type: 'text', text: 'Authorization required for this operation' }],
      isError: true,
    };
  }

  // Proceed — authResult.allowedScope constrains what we can do
  return performAction(params, authResult.allowedScope);
}

SkillAudit detection

SkillAudit's Security axis checks for capability escalation through static analysis of parameter flow: whether tool handlers accept credentials as parameters, whether shared state objects are writable by multiple tools with different trust levels, and whether authorization checks are present at each privileged call site or only at a top-level gate. The Permissions Hygiene axis cross-checks declared permissions against the actual capability surface inferred from the implementation.

Run a SkillAudit scan on your MCP server to see which capability escalation patterns appear in your implementation before publishing to a public directory.


Related: Permission scope security · Permission scope patterns · SSRF security