Why Your MCP Bridge Crashes at 3 AM (and How to Fix It)
You wake to a Slack alert: your agent workflow died mid-render. The logs show nothing. The MCP server just... vanished. You restart it, re-run the prompt, and twenty seconds later it happens again. This time you catch the real culprit: an unhandled JSON-RPC exception followed by a 40 MB timeline object that obliterated the agent's context window before it could ask a single useful question.
This is the production reality of bridging Model Context Protocol to local-first tools. The easy part is exposing one more tool. The hard part is making it survive real agent traffic.
The Architecture
Concat is a local-first media automation platform: the desktop application owns project state and rendering, while a JSON-RPC API makes those capabilities scriptable. Adding MCP lets Claude Code, Claude Desktop, or any agent discover and invoke those operations. The boundary looks like this:
Agent
|
| MCP over stdio
v
MCP adapter
|
| HTTP JSON-RPC
v
Concat desktop service
|
v
Local projects, media assets, render pipeline
Concat remains responsible for media state. The MCP adapter is a narrow protocol translator: validate arguments, enforce response limits, normalize failures, and return summaries instead of raw internal objects.
A Production-Grade MCP Adapter
The following server assumes Concat exposes a JSON-RPC endpoint at http://127.0.0.1:8787/rpc. The adapter forwards a constrained set of operations and exposes a resource for compact project metadata.
Create src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const concatRpcUrl =
process.env.CONCAT_RPC_URL ?? "http://127.0.0.1:8787/rpc";
const server = new McpServer({
name: "concat-local",
version: "1.0.0",
});
const maxRetries = 3;
const baseDelayMs = 100;
async function concatCall(
method: string,
params: Record<string, unknown> = {},
attempt = 0,
): Promise<unknown> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
const response = await fetch(concatRpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
method,
params,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.status === 502 || response.status === 503) {
throw new Error(`Concat unavailable (HTTP ${response.status})`);
}
if (!response.ok) {
throw new Error(`Concat returned HTTP ${response.status}`);
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 1_000_000) {
throw new Error(
`Response too large (${contentLength} bytes). Use paginated query.`,
);
}
const payload = (await response.json()) as {
result?: unknown;
error?: { code?: number; message?: string };
};
if (payload.error) {
throw new Error(
`Concat JSON-RPC error ${payload.error.code ?? "unknown"}: ` +
`${payload.error.message ?? "request failed"}`,
);
}
return payload.result;
} catch (error) {
const isRetriable =
error instanceof Error &&
(error.message.includes("502") ||
error.message.includes("503") ||
error.message.includes("ECONNREFUSED"));
if (isRetriable && attempt < maxRetries) {
const jitter = Math.random() * 0.3 + 0.85;
const delayMs = baseDelayMs * Math.pow(2, attempt) * jitter;
console.error(
`[concat-mcp] Retry ${attempt + 1}/${maxRetries} after ${Math.round(delayMs)}ms`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return concatCall(method, params, attempt + 1);
}
throw error;
}
}
function compact(value: unknown, maxBytes = 12_000): string {
const json = JSON.stringify(value);
if (json.length <= maxBytes) return json;
let safeCutoff = maxBytes;
while (safeCutoff > 0) {
const byte = json.charCodeAt(safeCutoff - 1);
if (byte < 0xd800 || byte > 0xdfff) break;
safeCutoff--;
}
return JSON.stringify({
truncated: true,
preview: json.slice(0, safeCutoff),
originalBytes: json.length,
message: "Response truncated by MCP adapter. Use detail query.",
});
}
server.tool(
"concat_project_summary",
"Return compact metadata for a Concat project.",
{ projectId: z.string().min(1).max(200).regex(/^[a-zA-Z0-9_-]+$/) },
async ({ projectId }) => {
try {
const result = await concatCall("project.get", {
projectId,
fields: ["id", "name", "durationMs", "trackCount", "updatedAt"],
});
return {
content: [{ type: "text", text: compact(result) }],
};
} catch (error) {
console.error(`[concat-mcp] Tool error:`, error);
return {
isError: true,
content: [{
type: "text",
text: error instanceof Error ? error.message : "Unknown Concat error",
}],
};
}
},
);
server.resource(
"concat-project",
"concat://project/{projectId}",
async (uri) => {
const projectId = uri.pathname.split("/").pop();
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId)) {
throw new Error("Invalid project ID");
}
const result = await concatCall("project.get", {
projectId,
fields: ["id", "name", "durationMs", "trackCount", "updatedAt"],
});
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: compact(result),
}],
};
},
);
let isShuttingDown = false;
process.on("SIGTERM", async () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.error("[concat-mcp] Received SIGTERM, draining...");
setTimeout(() => {
console.error("[concat-mcp] Drain timeout, forcing exit");
process.exit(1);
}, 5000);
});
process.on("uncaughtException", (error) => {
console.error("[concat-mcp] Fatal error:", error);
process.exitCode = 1;
});
process.on("unhandledRejection", (reason) => {
console.error("[concat-mcp] Unhandled rejection:", reason);
process.exitCode = 1;
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[concat-mcp] Server ready");
Install dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx
Run the server:
npx tsx src/server.ts
What Makes This Production-Ready
Exponential backoff with jitter. Transient failures (502, 503, connection refused) trigger up to three retries with randomized delays. This prevents thundering herd when Concat restarts.
Response size validation. The adapter checks Content-Length before parsing JSON. A 40 MB timeline is rejected immediately, not after consuming the agent's context budget.
UTF-8-safe truncation. The compact() function respects surrogate pair boundaries. A naive slice() can corrupt the last character and break JSON parsing.
Input validation with regex. Project IDs are constrained to alphanumeric patterns. This blocks path traversal attempts if Concat's RPC layer has insufficient validation.
Graceful shutdown. On SIGTERM, the adapter waits 5 seconds for in-flight calls before forcing exit. This prevents abandoned requests during orchestrator restarts.
Stderr-only logging. MCP stdio uses stdout for protocol frames. A single debug console.log to stdout corrupts the stream and produces cryptic parse failures. All observability goes to stderr.
Claude Desktop Configuration
The client must launch the adapter as a child process and pass the Concat endpoint through environment variables:
{
"mcpServers": {
"concat-local": {
"command": "npx",
"args": [
"tsx",
"/absolute/path/to/concat-mcp/src/server.ts"
],
"env": {
"CONCAT_RPC_URL": "http://127.0.0.1:8787/rpc"
}
}
}
}
Use an absolute path. Relative paths depend on the client's working directory and are the most common source of "server unavailable" reports. Also ensure Concat is running and listening before invoking the tool.
Operational Trade-offs
A stdio adapter is trivial to install and provides a strong local security boundary, but every client spawns its own process. That works for a single developer workstation but breaks down on shared render hosts. An HTTP MCP transport can centralize lifecycle management, but it introduces authentication, port exposure, and multi-user isolation concerns.
Truncation protects context capacity but hides information required for debugging. The practical compromise is to expose summary and detail operations separately, with explicit limit and cursor parameters. Never make the default response "everything."
For agent benchmarking across providers, I keep the Concat adapter independent from model routing. If testing fallback behavior or cost optimization, the model client can use a controlled relay like https://b-lost.com/v1. That test topology should not leak into the local media bridge. MCP should present stable capabilities regardless of which model is driving it.
Concat's local-first design makes this separation valuable: the application remains deterministic and scriptable, while the MCP layer becomes a small, observable compatibility surface rather than another source of state corruption.
The Hard Question
The biggest unresolved tension is when to fail fast versus retry silently. If Concat is restarting after a crash, the agent benefits from transparent retries. But if Concat is genuinely overloaded, those retries make the problem worse. Circuit breakers help, but they require shared state across stdio processes—which reintroduces exactly the coordination complexity that local-first architectures are designed to avoid.
How are you handling retry budget and backpressure in your MCP adapters? Are you using in-process state, external coordination (Redis, etcd), or just failing loud and letting the orchestrator decide? I'd love to hear what's working in production.
Top comments (2)
I'd keep the retry budget in-process and small, then fail loud. Once retries are coordinated across processes you're building a queue, and the orchestrator has better context on whether to try again anyway.
Good write-up, the response size cap and returning summaries instead of raw objects are the right call. One thing to double check: the size guard only looks at the content-length header, and responses sent with chunked encoding often don't have that header, so a big payload could still get through to response.json(). Also, clearTimeout runs before the body is read, so a stalled body wouldn't be covered by the 15 second abort. Reading the body with a byte limit (or checking the length after reading as text) closes both gaps.