Topic: mcp server business logic security

MCP server business logic security — state machine and flow abuse in AI tool handlers

Business logic vulnerabilities are flaws in the intended flow of an application — not in the implementation of a specific operation, but in the sequence, preconditions, and invariants that govern how operations relate to each other. MCP servers introduce a new dimension to this class: the AI model can invoke tools in any order it chooses, at any frequency, potentially in parallel. A flow that a human user would follow linearly — create cart, add items, pay, fulfill — an AI model might execute non-linearly, repetitively, or in reverse. If the tools don't enforce the intended state machine server-side, the model (or a prompt-injection attack shaping its behavior) can exploit the gaps.

Why AI-driven tool invocation creates new business logic risks

In a traditional web application, business logic flow is enforced partly by the UI — the user sees a form, fills it out, submits it, and the next step. The server enforces the rules, but the UI also constrains what calls are even possible at each step. The AI model has no such constraint: it sees a list of available tools and can invoke any of them at any point in a session.

This creates several categories of flow abuse that don't exist (or are much harder) in human-driven applications:

Three business logic enforcement patterns

1. Server-side state verification before every dependent operation

// VULNERABLE: fulfillment tool trusts that payment has occurred
// because the AI "should" have called chargePayment first
server.tool('fulfillOrder', z.object({
  orderId: z.string(),
}), async ({ orderId }) => {
  // No verification that payment exists — trusts call sequence
  await fulfillOrder(orderId)
  return { fulfilled: true }
})

// SAFE: verify payment status from the database before every fulfillment
server.tool('fulfillOrder', z.object({
  orderId: z.string(),
}), async ({ orderId }) => {
  const order = await db.orders.findUnique({
    where: { id: orderId },
    select: { status: true, paymentId: true, paymentStatus: true },
  })

  if (!order) return { error: 'Order not found' }
  if (order.paymentStatus !== 'captured') {
    return { error: `Cannot fulfill order: payment status is ${order.paymentStatus}` }
  }
  if (order.status !== 'paid') {
    return { error: `Cannot fulfill order: order status is ${order.status}` }
  }

  await fulfillOrder(orderId)
  return { fulfilled: true }
})

2. Idempotency keys for financial and quota-consuming operations

// Pattern: idempotency key prevents double-execution on retry
server.tool('chargePayment', z.object({
  orderId: z.string(),
  amountCents: z.number().int().positive(),
  idempotencyKey: z.string().uuid(),  // caller-supplied unique key per attempt
}), async ({ orderId, amountCents, idempotencyKey }) => {
  // Check if this key was already used
  const existing = await db.payments.findUnique({
    where: { idempotencyKey },
  })

  if (existing) {
    // Return the previous result without charging again
    return {
      chargeId: existing.chargeId,
      status: existing.status,
      idempotent: true,
    }
  }

  // First invocation: execute the charge
  const charge = await stripe.charges.create({
    amount: amountCents,
    currency: 'usd',
    metadata: { orderId, idempotencyKey },
  }, { idempotencyKey })

  // Record the result so retries return it
  await db.payments.create({
    data: { orderId, idempotencyKey, chargeId: charge.id, status: charge.status },
  })

  return { chargeId: charge.id, status: charge.status, idempotent: false }
})

3. Session-level tool call rate limiting

// Pattern: rate limit financially impactful tools per session
// Stored in Redis or database — not in-memory session state
// (in-memory state can be bypassed by reconnecting)

async function checkSessionRateLimit(
  sessionId: string,
  toolName: string,
  maxPerSession: number,
): Promise<{ allowed: boolean; count: number }> {
  const key = `session:${sessionId}:tool:${toolName}`
  const count = await redis.incr(key)

  if (count === 1) {
    // First call — set expiry for session lifetime
    await redis.expire(key, 3600)  // 1 hour
  }

  return { allowed: count <= maxPerSession, count }
}

server.tool('chargePayment', chargeSchema, async ({ orderId, ... }, ctx) => {
  const { allowed, count } = await checkSessionRateLimit(
    ctx.sessionId,
    'chargePayment',
    1,  // at most 1 charge per session — idempotency key handles retries
  )

  if (!allowed) {
    return { error: `chargePayment has been called ${count} times this session; use idempotencyKey to retry` }
  }

  // proceed with charge
})

What SkillAudit checks

See also

Check your MCP server for business logic vulnerabilities in multi-step tool flows.

Run a free audit → How grading works →