Published 3 June 2026 · Blog

Threat modeling an MCP server from scratch: the STRIDE approach

Most MCP servers ship without a threat model. Not because the developers are careless — because the MCP ecosystem is new enough that the attack surface is not well documented, and many teams assume a security checklist is a sufficient substitute. It is not. STRIDE gives you a structured way to enumerate entire classes of attacks before you write a single test, without needing a red team or a penetration tester. Here is how to apply it specifically to MCP server architecture.

STRIDE — Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege — was developed at Microsoft in the late 1990s as a systematic way to walk through a system's trust boundaries and ask: what is the worst thing an adversary could do here? The framework is not magic. It does not find bugs automatically. What it does is prevent the most common failure mode in security design: the failure to ask a category of question at all.

Most MCP server security discussions focus on prompt injection and credential handling — two important attack vectors, but two specific instances of a much larger attack space. A team that spends two days hardening against prompt injection and never considers repudiation may ship a server that faithfully resists injected instructions but leaves no audit trail of the destructive actions it takes. STRIDE makes those omissions visible.

This article applies STRIDE to a realistic MCP server architecture. Each section covers what the threat category means in the MCP context (not abstract definitions), a concrete attack scenario with a real tool name and payload, mitigations with code, and the SkillAudit detection that catches each pattern statically.

The MCP server threat surface

Before applying STRIDE, map the trust boundaries. In a typical MCP server deployment, there are three:

Boundary 1 — Tool caller → MCP server

The entity making tool calls — usually an LLM orchestrator, a Claude client, or an automated pipeline — sends JSON arguments to the server's tool handlers. This boundary is where input validation, rate limiting, and identity verification must occur. The caller is untrusted by default: even if the immediate caller is a legitimate LLM, it may be operating on attacker-controlled content (the prompt injection threat). Any claim the caller makes about its own identity arrives on this channel and can be forged.

Boundary 2 — MCP server → backend / resources

The MCP server acts as a privileged intermediary, calling databases, APIs, filesystems, and external services on behalf of the caller. This boundary is where the server's own ambient credentials create risk: a successful attack on Boundary 1 propagates to Boundary 2 with the server's full permission set. The MCP server is trusted by the backend — the backend does not know whether the request originated from legitimate tool use or an attacker who reached the handler.

Boundary 3 — Tool description → model trust (unique to MCP)

Tool descriptions are read by the LLM to understand what tools exist and how to use them. This creates a trust boundary that does not exist in traditional software: the model may make security-relevant decisions (what data to include in output, what tools to call next) based on the text of tool descriptions. Malicious or overly verbose descriptions can manipulate model behavior without ever touching the tool handler itself. This boundary is unique to agent architectures and has no direct analogue in the STRIDE literature.

Every STRIDE threat operates across one or more of these boundaries. Keeping the diagram in mind as you read each section makes the mitigations concrete rather than abstract.

SSpoofing — trusting caller-supplied identity

In the MCP context, spoofing means an attacker convincing the server that a tool call comes from a different, more privileged caller than the actual sender. The most common form is not a sophisticated session hijack — it is simply that the server reads an identity claim from the tool's arguments and uses it to make authorization decisions.

Attack scenario

The execute_pipeline tool accepts a caller_id argument that the server uses to look up per-caller rate limits:

// Tool definition in the server
{
  name: 'execute_pipeline',
  description: 'Trigger a CI pipeline run.',
  inputSchema: {
    type: 'object',
    properties: {
      pipeline: { type: 'string' },
      caller_id: { type: 'string', description: 'Identifier of the calling system' }
    }
  }
}

// Handler — vulnerable implementation
async function executePipeline({ pipeline, caller_id }) {
  const limits = await rateLimitStore.get(caller_id);
  if (limits.requestsThisHour >= limits.maxRequestsPerHour) {
    throw new McpError(ErrorCode.InvalidRequest, 'Rate limit exceeded');
  }
  // ... trigger pipeline
}

An attacker passes caller_id: "admin-pipeline". The server looks up the rate limit for admin-pipeline instead of the actual caller, bypasses the per-caller cap, and triggers unlimited pipeline runs — potentially triggering thousands of expensive build minutes or a CI-based cryptomining attack.

Mitigations

The root cause is deriving privilege from tool arguments. Tool arguments are attacker-controlled. Identity must come from a channel the server controls — the session, the transport, or a signed token verified out-of-band before any tool call is processed.

// Correct: identity resolved from session context, not arguments
async function executePipeline({ pipeline }, context) {
  // context is populated from the authenticated session at transport layer
  const callerId = context.session.callerId;

  if (!callerId) {
    throw new McpError(ErrorCode.InvalidRequest, 'No authenticated session');
  }

  const limits = await rateLimitStore.get(callerId);
  if (limits.requestsThisHour >= limits.maxRequestsPerHour) {
    throw new McpError(ErrorCode.InvalidRequest, 'Rate limit exceeded');
  }

  await triggerPipeline(pipeline, { actor: callerId });
}

If the MCP transport does not carry session context, add an API-key header at the transport layer and resolve the caller identity in server middleware before any handler runs. A tool handler that accepts an identity argument should be treated as a smell requiring code review, not a pattern to standardize.

For more on how permission scoping interacts with caller identity, see the permission scope patterns guide.

SkillAudit: mcp-spoofed-caller-id

TTampering — TOCTOU on temp files and unsigned audit logs

Tampering in the MCP context covers two distinct surfaces: the server's intermediate state between tool calls, and the server's own audit records. Both are writable by an attacker who can influence tool execution — and in multi-step agentic workflows, that attacker may be the LLM itself, operating on injected instructions.

Attack scenario: TOCTOU on temp files

The process_document tool writes an intermediate result to a predictable temp path and reads it back in a subsequent tool call:

// Vulnerable: predictable temp path written, then read
async function processDocument({ content }) {
  const tmpPath = `/tmp/mcp-doc-${Date.now()}.json`;
  await fs.writeFile(tmpPath, JSON.stringify({ content }));
  return { tmpPath };  // Path returned to caller for use in next tool call
}

async function finalizeDocument({ tmpPath }) {
  // TOCTOU window: attacker writes to tmpPath between calls
  const data = await fs.readFile(tmpPath, 'utf8');
  return JSON.parse(data);
}

Between the processDocument write and the finalizeDocument read, an attacker (or an injected instruction that reaches a shell tool) writes malicious content to the same temp path. The server reads the attacker-controlled file with whatever trust was established during the write phase.

Mitigations

import { randomBytes, createHmac } from 'crypto';
import { stat } from 'fs/promises';

async function processDocument({ content }) {
  // Cryptographic nonce: attacker cannot predict or collide with path
  const nonce = randomBytes(32).toString('hex');
  const tmpPath = `/tmp/mcp-doc-${nonce}.json`;

  const payload = { content, writtenAt: Date.now() };
  const hmac = createHmac('sha256', process.env.MCP_SIGNING_KEY!)
    .update(JSON.stringify(payload))
    .digest('hex');

  await fs.writeFile(tmpPath, JSON.stringify({ ...payload, hmac }));
  return { tmpPath };
}

async function finalizeDocument({ tmpPath }) {
  // Verify path is within expected temp directory
  const resolved = path.resolve(tmpPath);
  if (!resolved.startsWith('/tmp/mcp-doc-')) {
    throw new McpError(ErrorCode.InvalidRequest, 'Invalid temp path');
  }

  const raw = await fs.readFile(resolved, 'utf8');
  const { content, writtenAt, hmac, ...rest } = JSON.parse(raw);

  // Verify HMAC before trusting the content
  const expected = createHmac('sha256', process.env.MCP_SIGNING_KEY!)
    .update(JSON.stringify({ content, writtenAt }))
    .digest('hex');

  if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(expected))) {
    throw new McpError(ErrorCode.InternalError, 'Temp file integrity check failed');
  }

  // Also check mtime: file should not have been modified after write
  const fileStat = await stat(resolved);
  if (fileStat.mtimeMs > writtenAt + 500) {
    throw new McpError(ErrorCode.InternalError, 'Temp file was modified after write');
  }

  return { content };
}

For the audit log tampering variant: if the server writes audit log entries as plain JSON to a file and those entries can be deleted or modified, an attacker can erase the record of their activity. HMAC each log entry using an append-only signing key, and write to a log backend (S3 with object lock, or a write-once database) that prevents modification of existing records.

SkillAudit: mcp-toctou-temp-file

RRepudiation — irreversible actions with no audit trail

Repudiation means an actor can deny having performed an action because no durable record exists. In MCP servers, this is extremely common: tools that send emails, create records, execute scripts, or transfer funds often have no structured audit log. When something goes wrong, there is no timeline, no attribution, and no way to determine which tool call caused the outcome.

Attack scenario

The send_notification tool sends an email on behalf of the authenticated user. It has no audit log. An attacker who gains tool call access — via prompt injection in a prior read operation — sends 3,000 emails to internal addresses. When discovered, there is no server-side record: which tool call? which caller session? which arguments? The mail provider's sent log exists, but it cannot be correlated back to the MCP session without additional instrumentation.

Mitigations

Write a structured audit record before the action executes (intent log) and again after (result log). The pre-execution record ensures that even if the server crashes during execution, there is a record that the action was attempted. The post-execution record captures the outcome.

interface AuditEntry {
  auditId: string;          // UUIDv4 — correlates intent + result records
  sessionId: string;        // From authenticated session context
  actor: string;            // Resolved caller identity (never from tool args)
  toolName: string;
  argsHash: string;         // SHA-256 of JSON-serialized arguments
  timestamp: string;        // ISO 8601
  phase: 'intent' | 'result';
  outcome?: 'success' | 'error';
  errorCode?: string;
}

async function writeAuditEntry(entry: AuditEntry): Promise {
  const raw = JSON.stringify(entry);
  const mac = createHmac('sha256', process.env.AUDIT_SIGNING_KEY!)
    .update(raw)
    .digest('hex');

  // Append-only: write to object store or append-only log
  await auditLog.append({ ...entry, mac });
}

async function sendNotification({ to, subject, body }, context) {
  const auditId = randomUUID();
  const argsHash = createHash('sha256')
    .update(JSON.stringify({ to, subject, body }))
    .digest('hex');

  // 1. Intent record — written before action
  await writeAuditEntry({
    auditId,
    sessionId: context.session.id,
    actor: context.session.callerId,
    toolName: 'send_notification',
    argsHash,
    timestamp: new Date().toISOString(),
    phase: 'intent',
  });

  // 2. Execute action
  let outcome: 'success' | 'error' = 'success';
  let errorCode: string | undefined;

  try {
    await mailer.send({ to, subject, body });
  } catch (err) {
    outcome = 'error';
    errorCode = err instanceof Error ? err.constructor.name : 'UnknownError';
    throw new McpError(ErrorCode.InternalError, 'Failed to send notification');
  } finally {
    // 3. Result record — written regardless of outcome
    await writeAuditEntry({
      auditId,
      sessionId: context.session.id,
      actor: context.session.callerId,
      toolName: 'send_notification',
      argsHash,
      timestamp: new Date().toISOString(),
      phase: 'result',
      outcome,
      errorCode,
    });
  }
}

For more on audit logging as a security control, see the MCP server audit logging reference.

SkillAudit: mcp-missing-audit-trail

IInformation Disclosure — three MCP-specific leakage paths

Information disclosure in an MCP server takes forms that differ from traditional web applications. The three most common are error message leakage, tool description enumeration, and LLM output echo.

Path (a): verbose errors that echo tool arguments

A developer writes a query_database tool that accepts a connection_string argument for flexibility across environments. The handler does not catch the database driver's exception, which includes the full connection string (containing credentials) in its message. The MCP SDK wraps the uncaught error and returns it as the tool response. The LLM reads the error response and may include the connection string — credentials and all — in its visible reply to the user.

// Vulnerable
async function queryDatabase({ connection_string, query }) {
  const db = await connect(connection_string);  // Throws if malformed
  return db.query(query);
}

// Safe: never echo arguments in error messages
async function queryDatabase({ connection_string, query }) {
  let db;
  try {
    db = await connect(connection_string);
  } catch {
    // Log the full error internally with a reference ID
    const refId = randomUUID();
    logger.error({ refId, error: 'DB connection failed' });
    // Return a reference ID only — no argument values, no stack trace
    throw new McpError(ErrorCode.InternalError, `Database connection failed [ref:${refId}]`);
  }
  // ...
}

Path (b): tool descriptions that enumerate internal API shape

A call_internal_api tool description reads: "Calls the internal admin API at https://admin.internal:8443/v2/ — accepts endpoint names like /users, /billing/subscriptions, /config/feature-flags." An attacker who can read tool descriptions (which the LLM can be prompted to recite) now has a documented API inventory of the internal network. Tool descriptions should describe capability, not implementation.

// Leaky tool description — do not do this
{
  name: 'call_internal_api',
  description: 'Calls https://admin.internal:8443/v2/. Use endpoint=/users or endpoint=/billing/subscriptions.',
}

// Safe: describe behavior, not internals
{
  name: 'call_internal_api',
  description: 'Retrieves internal resource data by type. Valid types: users, billing, config.',
  inputSchema: {
    type: 'object',
    properties: {
      resourceType: {
        type: 'string',
        enum: ['users', 'billing', 'config']  // Allowlist, not free-form endpoint
      }
    },
    required: ['resourceType']
  }
}

Path (c): LLM echoing internal data from tool output

A tool returns a JSON object that includes internal fields (database IDs, internal usernames, system metadata) alongside the data the user requested. The LLM includes these fields in its response because nothing instructed it not to. Defense: filter tool output to the minimum necessary fields before returning from the handler, rather than returning the raw backend response.

// Leaky: return raw backend response
async function getUser({ userId }) {
  const user = await db.users.findById(userId);
  return { content: [{ type: 'text', text: JSON.stringify(user) }] };
  // user may contain: { id, email, name, internal_notes, admin_flags, stripe_id, ... }
}

// Safe: explicit projection of returnable fields
async function getUser({ userId }) {
  const user = await db.users.findById(userId);
  const safeUser = {
    id: user.id,
    name: user.name,
    email: user.email,
    // Internal fields explicitly excluded
  };
  return { content: [{ type: 'text', text: JSON.stringify(safeUser) }] };
}

The prompt injection kill chain — which starts with information disclosure via LLM echo — is covered in detail in the anatomy of a prompt injection attack.

SkillAudit: prompt-injection-surface

DDenial of Service — amplification via unbounded queries and sub-tool loops

MCP servers are effective amplifiers for denial-of-service attacks because each tool call can fan out to expensive backend operations. Two vectors are particularly dangerous.

Vector (a): unbounded query amplification

The search_records tool accepts a limit parameter. The handler passes it directly to the database query. An attacker sends { query: "a", limit: 999999 }. The database attempts to return 999,999 rows, holds the connection open for 45 seconds, exhausts the connection pool, and blocks all other tool calls on the server.

// Vulnerable: limit passed directly to backend
async function searchRecords({ query, limit }) {
  return db.search(query, { limit });  // limit: 999999 → full table scan
}

// Safe: clamp + timeout + circuit breaker
const MAX_SEARCH_LIMIT = 100;
const QUERY_TIMEOUT_MS = 5_000;

async function searchRecords({ query, limit }, context) {
  const safeLimit = Math.min(
    Math.max(1, Math.floor(Number(limit) || 10)),
    MAX_SEARCH_LIMIT
  );

  const result = await Promise.race([
    db.search(query, { limit: safeLimit }),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Query timeout')), QUERY_TIMEOUT_MS)
    ),
  ]);

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

Vector (b): multi-agent sub-tool loop amplification

In a multi-agent deployment, the analyze_content tool calls a sub-agent that also calls analyze_content if it determines the content is complex. An attacker sends a payload designed to be classified as "complex" at every level of recursion. The server spawns exponentially growing sub-agent calls: 1 → 4 → 16 → 64 — each consuming connections, CPU, and external API quota. This is the agent equivalent of a ReDoS or hash collision amplification attack.

// Safe: explicit recursion depth tracking + circuit breaker
interface AnalysisContext {
  recursionDepth: number;
  callId: string;
}

const MAX_RECURSION_DEPTH = 3;
const activeAnalyses = new Map<string, number>();

async function analyzeContent({ content }: { content: string }, ctx: AnalysisContext) {
  const depth = ctx.recursionDepth ?? 0;

  if (depth >= MAX_RECURSION_DEPTH) {
    return {
      content: [{ type: 'text', text: JSON.stringify({ truncated: true, depth }) }],
    };
  }

  // Circuit breaker: reject if too many concurrent analyses
  const concurrent = activeAnalyses.size;
  if (concurrent >= 20) {
    throw new McpError(ErrorCode.InvalidRequest, 'Analysis capacity exceeded, retry later');
  }

  const analysisId = randomUUID();
  activeAnalyses.set(analysisId, Date.now());

  try {
    const result = await subAgent.analyze(content, {
      recursionDepth: depth + 1,
      callId: analysisId,
    });
    return result;
  } finally {
    activeAnalyses.delete(analysisId);
  }
}

See also property-based fuzzing for how to generate adversarial limit values automatically in tests, and the security checklist for a broader treatment of resource control.

SkillAudit: mcp-unbounded-resource-query

EElevation of Privilege — overly broad permission scoping

Elevation of privilege in an MCP server typically does not require a memory corruption exploit. It happens because the server grants the same permission set to all tools, even though only a subset of tools need write access. An attacker who can invoke any tool — including via prompt injection in a read-only tool — arrives at the write tools with full permissions already granted.

Attack scenario

The server declares a single permissions object that grants files.read, files.write, database.read, database.write, and email.send. It exposes six tools: read_file, list_directory, search_database, write_file, update_record, and send_notification. An attacker injects an instruction into a document that read_file processes. The injected instruction asks the model to call send_notification. The server does not check whether the caller is permitted to use send_notification — all tools share the same permission set, and the permission has already been granted at startup.

// Vulnerable: all tools share a single capability grant
const globalPermissions = {
  'files.read': true,
  'files.write': true,
  'database.read': true,
  'database.write': true,
  'email.send': true,
};

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  // No per-tool permission check
  return toolHandlers[req.params.name](req.params.arguments, globalPermissions);
});

Mitigations

Scope permissions to the minimum required by each tool, and check the per-tool scope on every invocation:

// Per-tool permission declaration — minimal capability
const TOOL_PERMISSIONS: Record<string, Set<string>> = {
  read_file:         new Set(['files.read']),
  list_directory:    new Set(['files.read']),
  search_database:   new Set(['database.read']),
  write_file:        new Set(['files.read', 'files.write']),
  update_record:     new Set(['database.read', 'database.write']),
  send_notification: new Set(['email.send']),
};

function assertToolPermission(toolName: string, required: string): void {
  const allowed = TOOL_PERMISSIONS[toolName];
  if (!allowed || !allowed.has(required)) {
    throw new McpError(
      ErrorCode.InvalidRequest,
      `Tool '${toolName}' does not have permission: ${required}`
    );
  }
}

// Handler with per-tool permission gate
async function writeFile({ path, content }, context) {
  assertToolPermission('write_file', 'files.write');
  // ...proceed with write
}

async function sendNotification({ to, subject, body }, context) {
  assertToolPermission('send_notification', 'email.send');
  // ...proceed with send
}

In addition to runtime checks, declare the minimum permission set in the MCP server manifest. This allows host environments (Claude Desktop, orchestrators) to display the actual capability scope to users before authorizing the server. Overly broad declarations also surface as a signal in threat model reviews — if a read tool declares files.write, the declaration itself is a finding.

The permission scope patterns guide covers how to structure permission hierarchies across multi-tool servers, including patterns for delegation and ambient authority reduction.

SkillAudit: over-declared-permissions

Putting it together — the 30-minute threat model

A threat model does not need to be a formal document. For a single MCP server, a 30-minute structured exercise covers the essential ground. Here is the method:

  1. Draw the three trust boundaries on a whiteboard or in a shared doc: caller → MCP server, MCP server → backend, tool description → model. Label the trust level on each side of each boundary (who trusts whom, and on what basis).
  2. List every tool the server exposes. For each tool, note which boundaries it touches, what it reads, and what it writes or triggers.
  3. Apply each STRIDE letter to each boundary. Ask: at this boundary, how could an attacker perform Spoofing? Tampering? Repudiation? and so on. You do not need to answer — only to ask. Empty cells are findings.
  4. Record threats in a table with: boundary, STRIDE category, threat description, exploitability estimate (High / Medium / Low), and mitigation status (None / Partial / Complete).
  5. Prioritize by exploitability. Threats at the caller → MCP server boundary are exploitable by any entity that can invoke a tool — this includes prompt-injected LLMs. Threats at the MCP server → backend boundary are exploitable by anyone who can influence tool execution. Prioritize accordingly.

Example output table (three rows from a document processing server):

Boundary STRIDE Threat Exploitability Mitigation status
Caller → MCP S — Spoofing process_document reads caller_id from args to apply rate limits; any caller can pass caller_id: "internal-scheduler" to bypass limit High None — not implemented
MCP → Backend T — Tampering Intermediate result written to /tmp/mcp-{timestamp}.json; predictable path allows attacker to write malicious content in TOCTOU window High Partial — nonce used, HMAC not yet implemented
Description → Model I — Info Disclosure fetch_internal description includes internal hostname admin.corp.internal:9090; model can recite this to attacker via prompt Medium None — description not reviewed post-deployment

Six STRIDE letters across three boundaries gives eighteen cells. In practice, four to eight of those cells will contain real threats worth recording. The rest will be empty or low-confidence. That ratio is normal — the value is not in filling every cell, it is in proving you asked every question.

What SkillAudit checks automatically

Static analysis cannot replace threat modeling — it cannot know your system's intended behavior, its trust model, or the value of the assets it protects. What it can do is flag specific code patterns that correspond to each STRIDE category, turning abstract threat categories into concrete scan findings.

STRIDE category SkillAudit check What it looks for
S — Spoofing mcp-spoofed-caller-id Tool handlers that read identity claims (caller_id, user, actor) from tool arguments and use them in authorization or rate-limiting logic
T — Tampering mcp-toctou-temp-file Writes to predictable temp paths (e.g., /tmp/mcp-${timestamp}) followed by reads in separate tool handlers, without HMAC or mtime verification
R — Repudiation mcp-missing-audit-trail Tool handlers that perform irreversible actions (email send, database writes, process execution) without a structured audit log write before and after execution
I — Info Disclosure prompt-injection-surface Tool descriptions containing hostnames, internal paths, or API endpoints; error handlers that re-echo argument values; handlers that return raw backend objects without field projection
D — Denial of Service mcp-unbounded-resource-query Numeric parameters (limit, count, depth) passed directly to database queries, HTTP requests, or recursive calls without clamping and timeout enforcement
E — Privilege Escalation over-declared-permissions Permission declarations that grant write or destructive capabilities to tools that only perform read operations; single global permission object shared across all tool handlers

Coverage is complementary, not redundant. SkillAudit's static analysis catches patterns that are present in the code regardless of whether a threat model was done. The threat model catches risks that are present in the design — missing controls that leave no code pattern to detect. A server that never implemented an audit trail has no code for static analysis to flag as wrong. The threat model surfaces that absence explicitly.

Static threat modeling and dynamic scanning

The right sequence is: threat model during design, static scan at the release gate, dynamic validation in staging. These are not alternatives — they operate at different phases and catch different classes of issue.

A threat model done in the planning phase informs which controls to build. It catches design-level issues — a missing audit trail, a trust boundary with no authentication, a permission model that does not differentiate between tools — before any code is written. Fixing a design-level issue during planning costs an afternoon. Fixing it after launch costs a migration, a customer notification, and potentially a security incident report.

Static analysis at the release gate catches implementation-level issues — the developer intended to add HMAC to the temp file, but the implementation reads Date.now() as the nonce instead of randomBytes(32). The threat model would have said "use a cryptographic nonce"; the static scan confirms whether the implementation does so correctly.

Dynamic scanning in staging — including property-based fuzzing and the checks SkillAudit runs against a live server — catches runtime behavior that neither the design nor the implementation review could verify: whether a timeout actually fires under load, whether error messages actually suppress argument echoing across all code paths, whether rate limiting holds under concurrent requests.

The CI/CD security pipeline article covers how to wire static and dynamic checks together as a release gate, so that threat model findings from the design phase have a corresponding automated verification before any release ships.

Run a free SkillAudit scan

Get STRIDE-mapped findings — spoofed identity patterns, missing audit trails, over-declared permissions, and more — for your MCP server in under five minutes.

Request a free audit

Related reading