Topic: mcp server api abuse security

MCP server API abuse security — scraping, credential stuffing, and parameter manipulation

MCP servers sit between an LLM and a set of capabilities. Most security analysis focuses on what malicious input can do to the server. But there's a second attack class: a malicious or compromised LLM that abuses the server's legitimate API through legitimate-looking calls — bulk data extraction via repeated pagination, credential stuffing against exposed auth endpoints, or parameter manipulation to access data outside the caller's scope. These attacks look like normal traffic, which makes them harder to detect and easier to miss in a static code review.

Abuse vector 1: LLM-driven scraping loops

An MCP server with a searchItems tool that supports pagination can be used by a compromised or misbehaving LLM to extract the entire database: start from page 1, collect all results, increment page, repeat. From the server's perspective, each call is a valid API call. From an abuse perspective, the session is extracting your entire dataset.

// Vulnerable: no session-level data transfer budget
async function searchItemsTool(params) {
  const { query, page = 1, limit = 100 } = params;
  // No cap on how many pages a session can fetch
  // No cap on total items returned per session
  return db.search(query, { page, limit });
}

// Protected: session-level data budget + pagination limits
const SESSION_BUDGETS = new Map(); // sessionId → { calls, rowsReturned }

async function searchItemsTool(params, context) {
  const MAX_ROWS_PER_CALL = 50;       // hard cap per call
  const MAX_ROWS_PER_SESSION = 500;   // session-level budget
  const MAX_CALLS_PER_SESSION = 20;   // call count budget

  const sessionId = context.sessionId;
  if (!SESSION_BUDGETS.has(sessionId)) {
    SESSION_BUDGETS.set(sessionId, { calls: 0, rowsReturned: 0 });
  }
  const budget = SESSION_BUDGETS.get(sessionId);

  if (budget.calls >= MAX_CALLS_PER_SESSION) {
    return {
      content: [{ type: 'text', text: 'Session search limit reached. Start a new session to continue.' }],
      isError: true,
    };
  }

  if (budget.rowsReturned >= MAX_ROWS_PER_SESSION) {
    return {
      content: [{ type: 'text', text: 'Session data limit reached.' }],
      isError: true,
    };
  }

  const limit = Math.min(params.limit ?? 20, MAX_ROWS_PER_CALL);
  const results = await db.search(params.query, { page: params.page ?? 1, limit });

  budget.calls++;
  budget.rowsReturned += results.items.length;

  return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}

// Cleanup session budgets on session end or timeout
function clearSessionBudget(sessionId) {
  SESSION_BUDGETS.delete(sessionId);
}

Abuse vector 2: credential stuffing via auth endpoints

MCP tools that accept credentials as parameters (for accessing a user's external service on their behalf) are credential validation endpoints. If there's no limit on failed auth attempts per session, a compromised LLM can use the tool to test stolen credential pairs.

// Auth attempt tracking per session
const AUTH_FAILURES = new Map(); // sessionId → failCount

async function connectExternalServiceTool(params, context) {
  const MAX_AUTH_FAILURES = 3; // after 3 failures, block the session
  const sessionId = context.sessionId;
  const failures = AUTH_FAILURES.get(sessionId) ?? 0;

  if (failures >= MAX_AUTH_FAILURES) {
    return {
      content: [{ type: 'text', text: 'Authentication attempts exceeded. Session locked.' }],
      isError: true,
    };
  }

  const { apiKey, service } = params;
  const result = await validateExternalCredential(service, apiKey);

  if (!result.valid) {
    AUTH_FAILURES.set(sessionId, failures + 1);
    // Return a fixed-delay response to slow credential stuffing
    await new Promise(r => setTimeout(r, 500 + Math.random() * 500));
    return {
      content: [{ type: 'text', text: 'Authentication failed' }],
      isError: true,
    };
  }

  AUTH_FAILURES.delete(sessionId); // reset on success
  return { content: [{ type: 'text', text: 'Connected successfully' }] };
}

Abuse vector 3: IDOR — parameter manipulation for unauthorized access

Insecure Direct Object Reference (IDOR) is when a tool accepts a resource ID parameter and returns the resource without verifying the caller has access to that specific resource. In MCP servers this is common in tools like getDocument(id), getUser(userId), or getAuditReport(reportId) — the tool trusts that the caller only provides IDs they own.

// VULNERABLE: trusts that the caller only passes their own IDs
async function getDocumentTool(params) {
  const doc = await db.getDocument(params.documentId);
  if (!doc) return { isError: true, content: [{ type: 'text', text: 'Not found' }] };
  return { content: [{ type: 'text', text: JSON.stringify(doc) }] };
}

// SAFE: verify caller owns the resource before returning it
async function getDocumentTool(params, context) {
  const doc = await db.getDocument(params.documentId);

  if (!doc) {
    // Return same error for not-found and unauthorized — prevents enumeration
    return { isError: true, content: [{ type: 'text', text: 'Document not found' }] };
  }

  // Ownership check: does the caller's verified identity own this document?
  if (doc.ownerId !== context.authenticatedUserId) {
    // Same error message as not-found — don't confirm the document exists
    return { isError: true, content: [{ type: 'text', text: 'Document not found' }] };
  }

  return { content: [{ type: 'text', text: JSON.stringify(doc) }] };
}

// For list tools: scope the query to the caller's identity, don't filter post-query
async function listDocumentsTool(params, context) {
  // WRONG: fetch all, filter by owner — wasteful and leaks count via timing
  // const all = await db.getAllDocuments();
  // return all.filter(d => d.ownerId === context.authenticatedUserId);

  // RIGHT: query includes the ownership constraint — no other documents fetched
  const docs = await db.getDocumentsByOwner(context.authenticatedUserId, {
    limit: Math.min(params.limit ?? 20, 50),
    cursor: params.cursor,
  });
  return { content: [{ type: 'text', text: JSON.stringify(docs) }] };
}

Detecting abuse patterns in production

Beyond prevention, detection is essential — especially for LLM-driven abuse that moves slowly to stay under rate limits. Log the following per session: total tool calls, unique tool names called, total data bytes returned, unique resource IDs accessed, and auth failure count. Sessions that access a high number of unique resource IDs (enumeration), or that return data volumes 5× the median, should trigger an anomaly alert.

// Session-level anomaly detection
function logSessionSummary(sessionId, stats) {
  const isAnomalous =
    stats.uniqueResourcesAccessed > 100 ||    // enumeration signal
    stats.totalBytesReturned > 1_000_000 ||   // bulk extraction signal
    stats.authFailures > 2 ||                  // credential stuffing signal
    stats.callCount > 50;                      // scraping loop signal

  process.stderr.write(JSON.stringify({
    event: isAnomalous ? 'SESSION_ANOMALY' : 'SESSION_END',
    sessionId,
    ...stats,
    ts: new Date().toISOString(),
  }) + '\n');
}

SkillAudit detection

SkillAudit's Security axis checks for API abuse prevention patterns: presence of per-session data budgets on pagination tools, absence of retry-unlimited auth endpoints, and IDOR patterns where resource-access tools don't include ownership verification in the database query. The Permissions Hygiene axis flags tools that return data beyond the declared scope of the tool's stated purpose.

Run a SkillAudit scan on your MCP server to identify IDOR patterns, unbounded pagination, and auth endpoints without lockout logic before they reach a public directory.


Related: Rate limiting deep dive · Input validation patterns · SSRF security