Topic: mcp server type confusion security

MCP server type confusion security — type coercion and polymorphic argument vulnerabilities in AI tool handlers

Type confusion vulnerabilities arise when code assumes an argument has one type but receives another — and the type mismatch triggers an unexpected code path with security consequences. In MCP servers written in JavaScript or TypeScript, JSON's inherent polymorphism (a value that looks like a number to the model may be sent as a string, an array, or even an object depending on how the model constructs the call) combines with JavaScript's permissive type coercion to create a class of vulnerabilities that static analysis misses and human code review rarely catches. The model doesn't know your argument is supposed to be a string — it treats JSON values as interchangeable.

Why JSON polymorphism creates type confusion

The MCP protocol carries tool arguments as JSON. JSON has six value types: string, number, boolean, null, array, and object. A well-behaved model follows the declared schema and sends the expected type. But several scenarios cause type divergence:

Vulnerability pattern 1: loose equality on security-critical checks

The most dangerous type confusion pattern is a loose equality check on a value used for security gating:

// Vulnerable
async function deleteResource(resourceId, isAdmin) {
  if (isAdmin == true) {  // == not ===
    await db.query('DELETE FROM resources WHERE id = ?', [resourceId]);
  }
}

With ==, JavaScript coerces before comparing. "true" == true is false (fortunately), but 1 == true is true. A model that sends {"isAdmin": 1} instead of {"isAdmin": true} passes the check. More dangerously, in some coercion chains, non-empty arrays and objects also coerce to truthy — [] == false is true in JavaScript, but !![] is true, so mixed boolean-context checks can produce unexpected results when the argument is an array.

Vulnerability pattern 2: array injection into string-expected path argument

Path construction from tool arguments is susceptible to type confusion when the expected type is string but an array is supplied:

// Vulnerable
async function readFile(filename) {
  const filepath = path.join(BASE_DIR, args.filename);
  return fs.readFile(filepath, 'utf8');
}

If args.filename is ["../etc/passwd"] (an array), path.join in Node.js will call .toString() on it, producing "../etc/passwd" — exactly the path traversal payload. The string validation that should catch ../ sequences typically checks typeof args.filename === 'string' first, but if the handler reaches path.join before that check, the array coercion bypasses it.

Vulnerability pattern 3: object injection into string comparison

Security checks that compare a user-supplied value against a string are bypassed when the value is an object with a custom toString override — not possible in plain JSON, but possible when argument deserialization is involved:

// Vulnerable — if args come through a custom deserializer
if (args.token === process.env.ADMIN_TOKEN) {
  // ... grant access
}

In pure JSON this is safe. But if the server uses a YAML deserializer, a BSON parser, or any format that supports rich types, an argument value may arrive as a type with a custom comparison method. The safe fix is explicit string coercion before comparison: String(args.token) === process.env.ADMIN_TOKEN.

Safe pattern: explicit type assertion at handler entry

The safest pattern is to assert types at the entry point of every handler, before any business logic runs:

function assertString(value, name) {
  if (typeof value !== 'string') throw new Error(`${name} must be a string`);
  return value;
}

function assertBoolean(value, name) {
  if (typeof value !== 'boolean') throw new Error(`${name} must be a boolean`);
  return value;
}

function assertInt(value, name, min = 0, max = Infinity) {
  if (!Number.isInteger(value) || value < min || value > max)
    throw new Error(`${name} must be an integer in [${min}, ${max}]`);
  return value;
}

server.setRequestHandler('tools/call', async (req) => {
  const name     = assertString(req.params.arguments.name, 'name');
  const isAdmin  = assertBoolean(req.params.arguments.isAdmin, 'isAdmin');
  const limit    = assertInt(req.params.arguments.limit, 'limit', 1, 100);
  // ...
});

Safe pattern: JSON Schema validation before handler dispatch

An alternative to per-field assertions is to validate the entire argument object against a JSON Schema before the handler runs:

import Ajv from 'ajv';
const ajv = new Ajv({ strict: true, coerceTypes: false });

const schema = {
  type: 'object',
  properties: {
    filename: { type: 'string', pattern: '^[a-zA-Z0-9._-]+$' },
    isAdmin:  { type: 'boolean' },
    limit:    { type: 'integer', minimum: 1, maximum: 100 }
  },
  required: ['filename'],
  additionalProperties: false
};

const validate = ajv.compile(schema);

function handleWithSchema(args) {
  if (!validate(args)) throw new Error(ajv.errorsText(validate.errors));
  // args are now type-safe — proceed with handler logic
}

Note coerceTypes: false — this prevents Ajv from silently coercing "123" to 123, which would mask the type mismatch rather than rejecting it.

SkillAudit detection

The Security axis flags type confusion risks through static analysis: == comparisons involving security-critical arguments, path.join calls with untyped arguments, and string operations on values that aren't explicitly asserted to be strings. The LLM-probe layer exercises type confusion by sending arguments as unexpected types — arrays where strings are expected, numbers where booleans are expected — and observing whether the server errors cleanly or produces unexpected behavior. Findings are classified MEDIUM for coercion-based bypass vectors and HIGH when path traversal or privilege escalation is demonstrable.

Run a free audit at skillaudit.dev to check your server's type handling, or see our input validation security guide and prototype pollution guide for related JavaScript type-safety patterns.