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 chain | Security | HIGH |
| Admin or write tools have no limit on calls per session — repeated write operations possible from a single prompt | Security | HIGH |
| No tool classification (read vs write) — chain-aware authorization impossible | Permissions | MEDIUM |
| Tool responses contain no sensitivity annotation — cross-server data flow policy cannot be enforced | Permissions | MEDIUM |
| No session operations log — chain pattern analysis not possible post-incident | Maintenance | LOW |
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.