MCP: invalid deploymentSelector fails with an unactionable base64 error naming neither the argument nor the tool

Author: aford22Created Aug 28, 2026Updated Sep 5, 2026

Summary

When an MCP client passes an invalid deploymentSelector to a Convex MCP tool, the server returns:

json
{"error":"The string to be decoded is not correctly encoded."}

This is a DOMException from atob(), surfaced with no indication of which argument or which tool was at fault. In agent-driven workflows it reads as a transport/payload failure rather than a bad argument, and we've repeatedly observed LLM agents misdiagnose it and escalate in unproductive directions — simplifying the query, splitting it into smaller queries, switching from runOneoffQuery to data — none of which can help, because every selector-taking tool shares the same decoder. Each retry is a wasted round-trip, and against a production deployment that's a real cost.

Root cause

decodeDeploymentSelector splits on : and calls atob() with no validation and no try/catch:

https://github.com/get-convex/convex-backend/blob/main/npm-packages/convex/src/cli/lib/mcp/requestContext.ts#L160-L163

typescript
function decodeDeploymentSelector(encoded: string) {
  const [_, serializedPayload] = encoded.split(":");
  return payloadSchema.parse(JSON.parse(atob(serializedPayload)));
}

Two things combine to make this hard to diagnose:

  1. The argument invites the mistake. Every selector-taking tool declares it as an unconstrained z.string() described only as "Deployment selector (from the status tool) to read data from.". Nothing in the type or description signals that it is an opaque <kind>:<base64-of-JSON> token rather than a deployment name — and the decoded payload contains the deployment name, so passing the name directly is a natural guess.

  2. The error loses its provenance. The generic handler in mcp.ts keeps only error.message, discarding the type and stack, so a base64 failure is indistinguishable from any other error:

    typescript
    } else if (error instanceof Error) {
      message = error.message;
    }

Reproduction

No Convex install required — this mirrors requestContext.ts:160-163 exactly:

javascript
const decode = (encoded) => {
  const [_, serializedPayload] = encoded.split(":");
  return JSON.parse(atob(serializedPayload));
};
for (const v of ["my-deployment-name", "prod", "", "prod:my-deployment-name"]) {
  try { decode(v); console.log(`${JSON.stringify(v).padEnd(26)} -> OK`); }
  catch (e) { console.log(`${JSON.stringify(v).padEnd(26)} -> ${e.constructor.name}: ${e.message}`); }
}

Output:

"my-deployment-name"       -> DOMException: The string to be decoded is not correctly encoded.
"prod"                     -> DOMException: The string to be decoded is not correctly encoded.
""                         -> DOMException: The string to be decoded is not correctly encoded.
"prod:my-deployment-name"  -> DOMException: Invalid character

The message varies by input, which is a useful fingerprint: a bare name yields "not correctly encoded", while a kind:name form yields "Invalid character" (the - is outside the base64 alphabet).

Verified present on main at time of filing, and in the published [email protected].

Suggested fix

Three changes, in descending order of value. The first alone would have prevented every misdiagnosis we observed.

1. Validate before decoding, and say what was wrong.

typescript
function decodeDeploymentSelector(encoded: string) {
  const [_, serializedPayload] = encoded.split(":");
  let decoded: string;
  try {
    decoded = atob(serializedPayload ?? "");
  } catch {
    throw new Error(
      `Invalid deploymentSelector: expected the opaque token returned by the \`status\` tool ` +
      `(format "<kind>:<base64>"), not a deployment name. Received: ${JSON.stringify(encoded)}. ` +
      `Call the \`status\` tool and pass its deploymentSelector value verbatim.`,
    );
  }
  return payloadSchema.parse(JSON.parse(decoded));
}

2. Constrain the argument schema so the MCP layer rejects it before the tool body runs, with the parameter name attached — and make the description state that it is an opaque token, not a name:

typescript
deploymentSelector: z
  .string()
  .regex(/^[A-Za-z]+:[A-Za-z0-9+/]+=*$/, "Must be the opaque token from the `status` tool, not a deployment name")
  .describe("Opaque deployment selector token returned by the `status` tool. Not a deployment name — pass it through verbatim."),

3. Preserve the error type in the mcp.ts catch block (e.g. include error.constructor.name) so a DOMException is never again mistaken for a Convex-side failure.

Related

#272 raised the ergonomics of this token from a different angle (opaque, must be fetched from status and repeated verbatim). This issue is narrower and orthogonal: not that the token is awkward, but that an incorrect one fails with an error that names neither the argument nor the tool. Fix (1) is worthwhile independent of any ergonomic redesign, and would remain useful even if the token were replaced by something friendlier.

Environment

  • [email protected] (also verified against main)
  • Node 22, macOS
  • MCP servers launched via npx -y convex@latest mcp start --deployment <name>

Source: get-convex/convex-backend