Topic: mcp server tool call chaining security

MCP server tool call chaining security — privilege escalation, circular chains, and multi-step attack paths

Authorization in MCP servers is typically evaluated per tool call: does this session have permission to call this tool with these arguments? But the LLM executes tools in sequence within a session, and a sequence of individually-authorized tool calls can produce a net effect that no individual call authorization would have permitted. This is tool call chaining: the read-enumerate-write attack pattern that treats multiple tools as stages of a single operation.

The read→enumerate→write attack pattern

A file system MCP server with three tools: listDirectory, readFile, and writeFile. Each tool is individually authorized and individually validates its arguments. A session with access to all three can still be exploited:

// Step 1: list — authorized, returns file names including config files
listDirectory({ path: '/etc' })
// Returns: ['passwd', 'shadow', 'sudoers', 'ssh/authorized_keys']

// Step 2: read — authorized, returns content of a sensitive file
readFile({ path: '/etc/passwd' })
// Returns: full contents of /etc/passwd — now in context

// Step 3: write — authorized, uses content from step 2 as the source of truth
// to craft a path traversal or write a modified version
writeFile({
  path: '/etc/passwd',
  content: modifiedPasswd  // LLM constructs this from step 2 output
})

Each individual tool call is authorized. The path in each call passes its individual validation. But the three-call chain produces a privilege escalation that modifying /etc/passwd represents. Single-call authorization cannot detect this — you need chain-aware controls.

Chain-aware authorization

The simplest chain-aware control is session-level operation classification. Classify every tool as either read or write, and enforce that once a session has made a write call, it cannot make additional read calls that could feed a subsequent write:

type ToolClass = 'read' | 'write' | 'admin';

const TOOL_CLASSES: Record<string, ToolClass> = {
  listDirectory: 'read',
  readFile: 'read',
  writeFile: 'write',
  deleteFile: 'write',
  executeCommand: 'admin',
};

interface SessionState {
  userId: string;
  operationsLog: Array<{ tool: string; class: ToolClass; timestamp: string }>;
  writeCount: number;
}

function checkChainPolicy(session: SessionState, toolName: string): void {
  const toolClass = TOOL_CLASSES[toolName] ?? 'write'; // unknown tools are write by default

  // Suspicious pattern: read after write in same session
  // A legitimate user who is writing rarely needs to re-read the same resource
  if (toolClass === 'read' && session.writeCount > 0) {
    const recentWrites = session.operationsLog
      .slice(-10)
      .filter(op => op.class === 'write');

    if (recentWrites.length >= 2) {
      // Multiple writes followed by read: pattern consistent with
      // enumerate → write → verify → next-target loop
      throw new AuthorizationError(
        'Suspicious tool call sequence: read after multiple writes. ' +
        'New read operations require session re-authentication.'
      );
    }
  }

  // Hard limit: admin tools can only be called once per session
  if (toolClass === 'admin') {
    const priorAdminCalls = session.operationsLog.filter(op => op.class === 'admin');
    if (priorAdminCalls.length >= 1) {
      throw new AuthorizationError(
        'Admin tools can only be called once per session. Start a new session.'
      );
    }
  }
}

Circular tool call detection

A circular tool chain is a sequence where the output of tool A provides input to tool B, whose output provides input to tool A again. Without detection, the LLM can loop indefinitely — consuming compute budget, burning through API rate limits, and producing unbounded effects on the underlying data store:

// Example circular pattern:
// search_tickets({query}) → returns ticket IDs
// summarize_ticket({id}) → calls search_tickets to find related tickets
// search_tickets → finds the same tickets again → loop

// Detect via session call graph
interface CallGraphNode {
  toolName: string;
  inputHash: string;  // SHA-256 of normalized args — same input = loop
  timestamp: string;
}

class CircularCallDetector {
  private callGraph: CallGraphNode[] = [];

  check(toolName: string, args: Record<string, unknown>): void {
    const inputHash = sha256(JSON.stringify(args, null, 0));

    // Detect exact repeat: same tool, same args
    const exactRepeat = this.callGraph.find(
      n => n.toolName === toolName && n.inputHash === inputHash
    );
    if (exactRepeat) {
      throw new Error(
        `Circular tool call detected: ${toolName} was already called with identical ` +
        `arguments at ${exactRepeat.timestamp}. Breaking potential infinite loop.`
      );
    }

    // Detect session depth: more than 50 tool calls total
    if (this.callGraph.length >= 50) {
      throw new Error(
        `Session tool call depth limit (50) exceeded. ` +
        `This may indicate an unintended loop. Start a new session.`
      );
    }

    this.callGraph.push({ toolName, inputHash, timestamp: new Date().toISOString() });
  }
}

Cross-server chain escalation

When multiple MCP servers are active in the same session, tool call chains can span servers. A chain that reads from a trusted internal server and writes to a less-trusted community plugin — or vice versa — creates an escalation path that neither server's individual authorization policy accounts for:

// Scenario: trusted internal server + community analytics plugin in same session
//
// Chain:
// internal.get_customer_export() → returns customer list (authorized)
// analytics_plugin.upload_dataset({ data: customerList }) → sends to third party
//
// The internal server authorized the read.
// The analytics plugin authorized the write.
// Neither server evaluated the cross-server data flow.

The mitigation is data classification at the response level: mark tool responses with a sensitivity tag, and refuse to pass tagged data to tools on servers with lower trust levels. This requires orchestrator-level enforcement — individual MCP servers cannot see each other's outputs. For deployments where you control the orchestrator, implement a data flow policy that blocks sensitive-tagged context from reaching untrusted server tools.

SkillAudit findings for tool call chaining

Finding Axis Severity
No session-level tool call depth limit — infinite loop possible via LLM-driven circular chainSecurityHIGH
Admin or write tools have no limit on calls per session — repeated write operations possible from a single promptSecurityHIGH
No tool classification (read vs write) — chain-aware authorization impossiblePermissionsMEDIUM
Tool responses contain no sensitivity annotation — cross-server data flow policy cannot be enforcedPermissionsMEDIUM
No session operations log — chain pattern analysis not possible post-incidentMaintenanceLOW

Run a free SkillAudit scan to check your MCP server for tool call chaining vulnerabilities. The Security and Permissions axis reports cover session-level authorization gaps that per-tool checks miss.