Topic: mcp server business logic bypass security
MCP server business logic bypass security — authorization skips, state machine attacks, argument manipulation
Business logic vulnerabilities in MCP tool handlers are flaws in the intended workflow rather than in input sanitization or cryptographic implementation. They allow callers to skip required authorization steps, jump to invalid state machine transitions, manipulate arguments to forge internal flags, or invoke operations in the wrong order to bypass controls. These flaws are invisible to static analysis tools because the code itself is correct — the problem is in the sequencing logic and trust boundaries between tools.
1. State machine bypasses via direct transition calls
An MCP server that models a multi-stage workflow (e.g., draft → review → approved → published) often enforces transitions in individual tool handlers without a central state machine guard. An attacker who calls publish_document directly — skipping the submit_for_review and approve_document steps — can bypass any checks performed in those skipped tools:
// VULNERABLE: each tool checks only its local precondition
// but not whether the prior steps were completed
server.tool("submit_for_review", { docId: z.string() }, async ({ docId }, ctx) => {
const doc = await db.documents.findById(docId);
if (doc.authorId !== ctx.userId) throw new ForbiddenError();
// Sets status = "pending_review" and emails reviewers
await db.documents.update(docId, { status: "pending_review" });
return { content: [{ type: "text", text: "Submitted for review" }] };
});
server.tool("approve_document", { docId: z.string() }, async ({ docId }, ctx) => {
if (!ctx.isReviewer) throw new ForbiddenError();
await db.documents.update(docId, { status: "approved" });
return { content: [{ type: "text", text: "Approved" }] };
});
server.tool("publish_document", { docId: z.string() }, async ({ docId }, ctx) => {
const doc = await db.documents.findById(docId);
// VULNERABLE: only checks if doc exists and caller is authorized to publish
// Does NOT check that the document went through submit_for_review + approve
if (!ctx.canPublish) throw new ForbiddenError();
await db.documents.update(docId, { status: "published", publishedAt: new Date() });
return { content: [{ type: "text", text: "Published" }] };
// An LLM agent with canPublish=true can publish a draft directly,
// bypassing the entire review workflow
});
// SECURE: central state machine guard enforces valid transitions
const VALID_TRANSITIONS: Record<string, string> = {
draft: "pending_review",
pending_review: "approved",
approved: "published",
};
async function assertValidTransition(docId: string, targetStatus: string) {
const doc = await db.documents.findById(docId);
if (!doc) throw new GraphQLError("Document not found");
const expectedFrom = Object.keys(VALID_TRANSITIONS).find(
(k) => VALID_TRANSITIONS[k] === targetStatus
);
if (doc.status !== expectedFrom) {
throw new GraphQLError(
`Invalid transition: document is in status '${doc.status}', ` +
`cannot move to '${targetStatus}' (requires '${expectedFrom}')`
);
}
}
server.tool("publish_document", { docId: z.string() }, async ({ docId }, ctx) => {
if (!ctx.canPublish) throw new ForbiddenError();
await assertValidTransition(docId, "published"); // Enforces approved → published
await db.documents.update(docId, { status: "published", publishedAt: new Date() });
return { content: [{ type: "text", text: "Published" }] };
});
2. Authorization skips via argument manipulation
Tool handlers that accept an isAdmin, bypassCheck, or similar flag as a tool argument — rather than deriving privilege from the authenticated context — allow callers to forge elevated access by simply passing the flag:
// VULNERABLE: trust flags passed as tool arguments
server.tool("delete_record", {
recordId: z.string(),
// DANGEROUS: caller controls this flag — can pass true even without admin role
adminOverride: z.boolean().optional(),
}, async ({ recordId, adminOverride }, ctx) => {
// If the LLM constructs tool calls with adminOverride: true, it bypasses auth
const canDelete = ctx.isAdmin || adminOverride === true;
if (!canDelete) throw new ForbiddenError();
await db.records.delete(recordId);
return { content: [{ type: "text", text: "Deleted" }] };
});
// SECURE: privilege derived exclusively from the authenticated session context
server.tool("delete_record", {
recordId: z.string(),
// No adminOverride argument — privilege comes from ctx only
}, async ({ recordId }, ctx) => {
if (!ctx.isAdmin) throw new ForbiddenError();
await db.records.delete(recordId);
return { content: [{ type: "text", text: "Deleted" }] };
});
3. Order-of-operations attacks — completing step 3 before step 1
Multi-step tool workflows (checkout flow, onboarding wizard, payment processing) that store progress in a shared session or database can be attacked by calling later steps before earlier steps have run. The later step may make assumptions that are only true if the earlier steps completed:
// VULNERABLE: charge_payment assumes set_payment_method was called first
server.tool("set_payment_method", { token: z.string() }, async ({ token }, ctx) => {
// Stores payment method in session
await sessions.set(ctx.sessionId, "paymentToken", token);
return { content: [{ type: "text", text: "Payment method set" }] };
});
server.tool("charge_payment", { amount: z.number() }, async ({ amount }, ctx) => {
// VULNERABLE: assumes set_payment_method already ran
const token = await sessions.get(ctx.sessionId, "paymentToken");
if (!token) throw new Error("No payment method set"); // Error, but too late
// If token is null and the payment processor has a null-token default card, this charges
// a different card than intended
await paymentProcessor.charge({ token, amount });
return { content: [{ type: "text", text: `Charged $${amount}` }] };
});
// SECURE: each step explicitly verifies all prerequisites
server.tool("charge_payment", { amount: z.number() }, async ({ amount }, ctx) => {
const paymentToken = await sessions.get(ctx.sessionId, "paymentToken");
if (!paymentToken) {
throw new GraphQLError("Payment method not set. Call set_payment_method first.", {
extensions: { code: "PREREQUISITE_MISSING", prerequisite: "set_payment_method" }
});
}
// Also verify the session hasn't expired since set_payment_method was called
const tokenAge = Date.now() - (await sessions.get(ctx.sessionId, "paymentTokenSetAt"));
if (tokenAge > 15 * 60 * 1000) {
throw new GraphQLError("Payment session expired. Re-enter payment method.");
}
await paymentProcessor.charge({ token: paymentToken, amount });
return { content: [{ type: "text", text: `Charged $${amount}` }] };
});
4. Duplicate operation replay — submitting the same order twice
MCP tool handlers for financial operations (payments, refunds, transfers) that lack idempotency guards can be replayed by an LLM agent that retries on timeout or error — doubling charges, credits, or transfers:
// VULNERABLE: no idempotency guard — retrying process_refund issues double refund
server.tool("process_refund", {
orderId: z.string(),
amount: z.number(),
}, async ({ orderId, amount }, ctx) => {
await paymentProcessor.refund({ orderId, amount });
return { content: [{ type: "text", text: `Refunded $${amount}` }] };
});
// SECURE: idempotency key prevents duplicate execution
server.tool("process_refund", {
orderId: z.string(),
amount: z.number(),
idempotencyKey: z.string().uuid(), // Caller must supply a unique key per operation
}, async ({ orderId, amount, idempotencyKey }, ctx) => {
// Check if this key was already used
const existing = await db.refundOps.findByKey(idempotencyKey);
if (existing) {
// Idempotent: return the same result as the first call
return { content: [{ type: "text", text: `Refund already processed: ${existing.refundId}` }] };
}
// Lock to prevent concurrent duplicate calls with same key
await db.refundOps.create({ idempotencyKey, status: "pending" });
const refund = await paymentProcessor.refund({ orderId, amount });
await db.refundOps.update(idempotencyKey, { status: "complete", refundId: refund.id });
return { content: [{ type: "text", text: `Refunded $${amount}: ${refund.id}` }] };
});
5. Mass assignment — accepting more fields than intended
Tool handlers that spread tool arguments directly onto database update objects can allow callers to set fields they shouldn't be able to modify — role escalation, status changes, or internal flags:
// VULNERABLE: spreads all tool arguments onto the update object
server.tool("update_profile", {
name: z.string().optional(),
bio: z.string().optional(),
// Caller can also pass: role, isAdmin, status, emailVerified
// if Zod schema uses .passthrough() or arguments are spread directly
}, async (args, ctx) => {
await db.users.update(ctx.userId, { ...args });
// If attacker passes { name: "alice", role: "admin" }, they escalate to admin
return { content: [{ type: "text", text: "Profile updated" }] };
});
// SECURE: explicit allowlist of updatable fields
server.tool("update_profile", {
name: z.string().max(128).optional(),
bio: z.string().max(1000).optional(),
}, async ({ name, bio }, ctx) => {
const updateData: Record<string, unknown> = {};
if (name !== undefined) updateData.name = name;
if (bio !== undefined) updateData.bio = bio;
// Only name and bio can be updated via this tool — role, status, isAdmin are not touched
await db.users.update(ctx.userId, updateData);
return { content: [{ type: "text", text: "Profile updated" }] };
});
SkillAudit findings for business logic bypass
Run a SkillAudit scan to detect privilege argument patterns, missing state machine guards, and financial tool idempotency gaps automatically. See also tool chaining attacks for how business logic bypasses compound with read-to-write privilege escalation.