Topic: mcp server JSON injection security

MCP server JSON injection security — string concatenation vulnerabilities

JSON injection occurs when an MCP server constructs a JSON string by concatenating or interpolating user-controlled input rather than using a proper serialization function. The attacker embeds JSON metacharacters — quotes, braces, colons — that break out of the intended string value and inject new fields into the JSON structure, with downstream effects ranging from field spoofing to log poisoning to request forgery.

Field injection via string interpolation

A common pattern in MCP tool handlers is to construct a JSON body for an API call using a template string:

// VULNERABLE — user input interpolated directly into JSON string
server.tool('create_user', {
  username: z.string(),
  email: z.string(),
}, async ({ username, email }) => {
  const body = `{"username": "${username}", "email": "${email}", "role": "user"}`;
  const response = await fetch('https://api.internal/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body,
  });
  return { content: [{ type: 'text', text: `Created: ${response.status}` }] };
});

An attacker passing email: "attacker@example.com", "role": "admin" produces:

{"username": "alice", "email": "attacker@example.com", "role": "admin", "role": "user"}

The downstream API parser may accept the first occurrence of the role field, not the last, depending on the language's JSON parser behavior. In JavaScript's JSON.parse, the last occurrence wins — so the attacker's injection would be overwritten. But in Python's json.loads, last occurrence also wins. In many other parsers (Go's encoding/json, Java's Jackson with non-default settings, Fastjson), the first occurrence wins — granting the injected role. The behavior is parser-dependent and cannot be relied on for safety.

A more severe injection escapes the object entirely. If the attacker can inject a closing brace and then a new top-level key:

// username = 'alice", "role": "admin"}'
// Resulting body: {"username": "alice", "role": "admin"}", "email": "...", "role": "user"}
// This is invalid JSON and will likely cause a parse error — but the error behavior
// (silently truncated? first object accepted?) is implementation-specific.

Log injection via JSON template strings

Structured logging with JSON template strings is a more insidious injection vector because it affects audit logs rather than API calls:

// VULNERABLE — user-controlled field injected into structured log entry
server.tool('search_documents', { query: z.string() }, async ({ query }) => {
  logger.info(`{"event": "search", "query": "${query}", "user": "${currentUser}"}`);
  // ...
});

An attacker passes query: 'test", "user": "admin", "event": "admin_access', producing a log entry that attributes the action to admin and labels it as an admin_access event — a false audit trail. In systems where log entries are parsed by a SIEM or fed into an alerting pipeline, injected log fields can suppress alerts, forge event types, or create phantom events that flood the monitoring system.

JSON injection in webhook and notification payloads

MCP servers that construct notification payloads by string interpolation are vulnerable to injecting arbitrary fields into the outgoing webhook body:

// VULNERABLE — note content interpolated into notification JSON
server.tool('create_note', { title: z.string(), content: z.string() }, async ({ title, content }) => {
  const notification = `{"type": "note_created", "title": "${title}", "preview": "${content.slice(0, 100)}"}`;
  await sendWebhook(NOTIFICATION_URL, notification);
  return { content: [{ type: 'text', text: 'Note created.' }] };
});

Injecting into title allows the attacker to add arbitrary fields to the notification payload. If the webhook recipient uses these fields to make decisions (routing, prioritization, access control), the injected fields influence those decisions.

The correct pattern: always serialize

The fix is unconditional: never construct JSON by string concatenation. Always build the data structure as a JavaScript object (or Python dict) and serialize it with JSON.stringify / json.dumps:

// CORRECT — object serialized rather than string-interpolated
server.tool('create_user', {
  username: z.string(),
  email: z.string(),
}, async ({ username, email }) => {
  const body = JSON.stringify({
    username,  // JSON.stringify escapes all metacharacters
    email,
    role: 'user',  // hardcoded, not injectable
  });
  const response = await fetch('https://api.internal/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body,
  });
  return { content: [{ type: 'text', text: `Created: ${response.status}` }] };
});

JSON.stringify escapes double quotes as \", backslashes as \\, control characters as \uXXXX, and angle brackets as < / > (in some implementations). The attacker's metacharacters become inert string data. The role field is set as a literal in the object definition and cannot be overridden by user input regardless of what username or email contain.

For structured logging, use a logging library that accepts structured fields as separate arguments rather than a pre-formatted string:

// CORRECT — structured fields passed separately, not interpolated
logger.info('search_performed', {
  event: 'search',
  query: query,   // logger serializes this safely
  user: currentUser,
});

SkillAudit's static analysis flags template literals and string concatenation patterns that appear to construct JSON — specifically patterns matching `{...${...}...}` or '{"' + variable — as JSON injection candidates. The check also covers Python f-strings that construct JSON (f'{{"key": "{value}"}}'), which are equally vulnerable.

Check your MCP server for JSON injection via string concatenation.

Run a free audit →