mastra-ai/mastra · error · MastraError

MCP_CLIENT_REDIRECT_BODY_NOT_REPLAYABLE

MCP_CLIENT_REDIRECT_BODY_NOT_REPLAYABLE

Error message

Cannot follow a ${response.status} redirect under the allowedHosts policy: the request body is not replayable (only string bodies can be re-sent).

What it means

Redirects that preserve the body (307/308, or 301/302 for non-POST methods) require resending the request body. Under the allowedHosts policy, if the current body is neither null nor a string (e.g. a stream, FormData, or an already-consumed one-shot body), following the redirect would re-send an unusable body, so MCP_CLIENT_REDIRECT_BODY_NOT_REPLAYABLE (USER category) is thrown.

Source

Thrown at packages/mcp/src/client/url-policy.ts:242

    // Release the redirect response's body so its socket can be reused.
    cancelResponseBody(response);

    const nextUrl = new URL(location, currentUrl);
    assertHostAllowed(nextUrl, allowedHosts, `A redirect from "${currentUrl.host}" pointed at it; the hop was not followed.`);

    const methodUpper = method.toUpperCase();
    const dropsBody =
      response.status === 303 || ((response.status === 301 || response.status === 302) && methodUpper === 'POST');
    if (dropsBody) {
      method = 'GET';
      body = undefined;
      headers.delete('content-type');
      headers.delete('content-length');
    } else if (body != null && typeof body !== 'string') {
      // Any hop that preserves the body (307/308 always; 301/302 for non-POST
      // methods) would re-send an already consumed one-shot body, so guard on
      // "the body is preserved", not on specific status codes.
      throw new MastraError({
        id: 'MCP_CLIENT_REDIRECT_BODY_NOT_REPLAYABLE',
        domain: ErrorDomain.MCP,
        category: ErrorCategory.USER,
        text: `Cannot follow a ${response.status} redirect under the allowedHosts policy: the request body is not replayable (only string bodies can be re-sent).`,
      });
    }

    // WHATWG Fetch strips Authorization when the ORIGIN (scheme + host + port)
    // changes — host alone is not enough: a same-host https→http downgrade must
    // also drop the header or the bearer token is re-sent in cleartext.
    if (nextUrl.origin !== currentUrl.origin) {
      headers.delete('authorization');
    }

    currentUrl = nextUrl;
  }
}

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Send the request body as a string (e.g. JSON.stringify(payload)) so it can be replayed across hops
  2. Target the post-redirect URL directly to avoid the redirect hop
  3. Buffer the body into a string before fetching
  4. Restructure the server so the endpoint does not redirect for body-bearing requests

Example fix

// before
await fetch(url, { method: 'POST', body: fs.createReadStream(path) });
// after
await fetch(url, { method: 'POST', body: JSON.stringify(payload), headers: { 'content-type': 'application/json' } });
Defensive patterns

Strategy: validation

Validate before calling

if (body != null && typeof body !== 'string') {
  throw new Error('Body must be a string (JSON.stringify it) to survive 307/308 redirects under allowedHosts policy');
}

Type guard

function isReplayableBody(b: unknown): b is string | null | undefined {
  return b == null || typeof b === 'string';
}

Try / catch

try {
  await client.tools();
} catch (e) {
  if (e instanceof MastraError && e.id === 'MCP_CLIENT_REDIRECT_BODY_NOT_REPLAYABLE') {
    console.error('Re-send with a stringified body or target the post-redirect URL directly');
  } else throw e;
}

Prevention

When it happens

Trigger: Sending a request with a non-string body (stream/Buffer/FormData) to a host that answers 307/308 redirect under an allowedHosts URL policy.

Common situations: Posting binary uploads or streaming JSON-RPC bodies to an MCP endpoint that now redirects (e.g. trailing-slash or domain migration); ReadableStream bodies consumed on the first hop.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/be6ae8778a8e7473. Report an issue: GitHub.