ruvnet/ruflo · error

MCP server " " returned HTTP

Error message

MCP server "${server.name}" returned HTTP ${httpStatus}: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}

What it means

During Streamable HTTP connect, a definitive non-429 4xx/5xx (per shouldSkipSseFallback: any 400–599 except 408) skips the SSE fallback and throws this error (clientPool.ts:164), embedding the HTTP status, the SDK error message, and the original error as cause. The failure is also recorded, triggering the cooldown map for subsequent attempts.

Solutions

  1. Reproduce outside the app: curl -v -X POST <url> with the same headers to see the raw status
  2. Fix the URL (usually append /mcp) or the auth headers in MCP_SERVERS
  3. For 5xx, check upstream server logs/proxy health
  4. After fixing, call evictFromPool(server) or wait ~5s so the recorded cooldown does not mask the fix

Example fix

# before (config)
MCP_SERVERS=[{"name":"tools","url":"https://mcp.example.com"}]  # 404 -> HTTP status error

# after
MCP_SERVERS=[{"name":"tools","url":"https://mcp.example.com/mcp"}]
Defensive patterns

Strategy: fallback

Validate before calling

// smoke-test the entry before adding it to MCP_SERVERS
async function probeMcpEntry(url: string, headers: Record<string, string>) {
	const res = await fetch(url, { method: "POST", headers });
	if (res.status >= 400) throw new Error(`MCP probe failed: HTTP ${res.status}`);
}

Type guard

function isMcpHttpStatusError(e: unknown): boolean {
	return e instanceof Error && /MCP server ".+" returned HTTP \d{3}/.test(e.message);
}

Try / catch

try {
	client = await getClient(server);
} catch (e) {
	if (isMcpHttpStatusError(e)) {
		// definitive upstream rejection: skip this server, keep session alive without its tools
		logger.warn({ err: e }, `[mcp] disabling server ${server.name}`);
		return null;
	}
	throw e;
}

Prevention

When it happens

Trigger: MCP server URL answering 401 (missing/wrong auth headers), 403 (forbidden key), 404 (wrong path — e.g. forgot the /mcp suffix), or 5xx on the initialize POST; thrown on the first connect for that url+headers key.

Common situations: MCP_SERVERS env entry with a typo'd path; token expired or lacking scopes; reverse proxy (nginx/traefik) misrouting /mcp; upstream deployed a breaking change returning 500 on initialize.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/b6270584b4428f5a. Report an issue: GitHub.

Appendix: source

Thrown at ruflo/src/ruvocal/src/lib/server/mcp/clientPool.ts:164

		// 4xx/5xx (except 408): falling back to SSE will hit the same upstream
		// and fail the same way. Surface a clean error with rate-limit info.
		if (shouldSkipSseFallback(httpStatus)) {
			try {
				await client.close?.();
			} catch {}

			recordFailure(key, httpStatus, httpErr);

			if (httpStatus === 429) {
				const retryAfterMs = extractRetryAfterMs(httpErr);
				throw new McpRateLimitedError(
					server.name,
					429,
					retryAfterMs,
					httpErr instanceof Error ? httpErr.message : String(httpErr)
				);
			}
			throw new Error(
				`MCP server "${server.name}" returned HTTP ${httpStatus}: ` +
					(httpErr instanceof Error ? httpErr.message : String(httpErr)),
				{ cause: httpErr instanceof Error ? httpErr : undefined }
			);
		}

		// Recoverable failure (network, 408, transport mismatch) — try SSE fallback.
		try {
			await client.connect(new SSEClientTransport(url, { requestInit }));
		} catch (err) {
			try {
				await client.close?.();
			} catch {}

			// Combine both errors for the caller. Honor 429 status if either side
			// surfaced a rate-limit (the upstream is rate-limiting independent of
			// transport).
			const sseStatus = statusFromTransportError(err);

View on GitHub (pinned to fa13ee4ad6)