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