Making MCP Tool Output Boring: A Production Integration with Context Mode
It's 2:47 AM. Your agent-driven deployment pipeline just burned through 900,000 tokens because npm test dumped 47,000 lines of Jest output into the model context. The MCP server faithfully serialized every byte. Claude Desktop dutifully ingested it. Your agent lost track of the original question halfway through and started hallucinating package names. The deploy failed. You're awake.
AI coding agents rarely fail because they cannot invoke a tool. They fail because the tool returns too much information.
That distinction matters when integrating mksglu/context-mode into an MCP-based development environment. Context Mode is designed to sandbox tool output, persist session memory, and route work across multiple agent platforms through MCP and hooks. Its most valuable property is not simply "smaller responses." It changes where context reduction happens: before large command output reaches the model context window.
I evaluated this architecture around one production failure mode: a repository search or test command emits tens of thousands of lines, the MCP server serializes the entire result, and the agent loses useful context before it can make a decision.
The architecture boundary
A reliable integration needs four separate responsibilities:
- Execution runs commands inside a controlled sandbox.
- Reduction converts raw output into a bounded summary.
- MCP transport exposes tools and resources over JSON-RPC stdio.
- Agent configuration starts the process and owns its lifecycle.
Context Mode belongs at the reduction and routing boundary. It should not be responsible for every domain-specific command, and an MCP server should not blindly proxy arbitrary subprocess output.
The practical data path is:
Claude Code or Claude Desktop
|
| JSON-RPC over stdin/stdout
v
MCP server
|
+--> bounded command runner
+--> session memory resource
+--> context-mode hooks and routing
|
v
sandboxed process output
The important invariant is that the model receives a bounded representation, not a compressed-looking copy of an unbounded log.
A hardened TypeScript MCP server
The following server exposes one bounded command tool and one session resource. It uses @modelcontextprotocol/sdk, keeps stdout reserved for JSON-RPC, and sends diagnostics to stderr. The output limit is deliberately enforced in the server rather than left to the client.
// src/server.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolve, normalize } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const execFileAsync = promisify(execFile);
const server = new McpServer({
name: "bounded-repo-tools",
version: "1.0.0",
});
const MAX_OUTPUT = 12_000;
const SESSION_TTL_MS = 3_600_000; // 1 hour
const sessions = new Map<string, { output: string; expires: number }>();
// Prune expired sessions every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [id, entry] of sessions.entries()) {
if (now > entry.expires) sessions.delete(id);
}
}, 300_000);
function validateCwd(cwd: string, allowedRoot: string): string {
const normalized = normalize(resolve(cwd));
const root = normalize(resolve(allowedRoot));
if (!normalized.startsWith(root)) {
throw new Error(`Path traversal blocked: ${cwd}`);
}
return normalized;
}
function bound(text: string): string {
if (text.length <= MAX_OUTPUT) return text;
return `${text.slice(0, MAX_OUTPUT)}\n...[truncated ${text.length - MAX_OUTPUT} bytes by server]`;
}
server.registerTool(
"run_repo_command",
{
description: "Run a safe repository command with bounded output.",
inputSchema: {
command: z.enum(["git-status", "test", "list-files"]),
cwd: z.string().min(1),
sessionId: z.string().min(1),
},
},
async ({ command, cwd, sessionId }) => {
const allowedRoot = process.env.REPO_ROOT || process.cwd();
let safeCwd: string;
try {
safeCwd = validateCwd(cwd, allowedRoot);
} catch (err) {
return {
isError: true,
content: [{ type: "text", text: `Security: ${err.message}` }],
};
}
const commands: Record<string, { file: string; args: string[] }> = {
"git-status": { file: "git", args: ["status", "--short"] },
test: { file: "npm", args: ["test", "--", "--runInBand"] },
"list-files": { file: "git", args: ["ls-files"] },
};
try {
const spec = commands[command];
const result = await execFileAsync(spec.file, spec.args, {
cwd: safeCwd,
timeout: 30_000,
maxBuffer: 256 * 1024, // Reduced to 256KB to leave headroom
shell: false,
});
const output = bound(`${result.stdout}${result.stderr}`);
sessions.set(sessionId, {
output,
expires: Date.now() + SESSION_TTL_MS,
});
return {
content: [{ type: "text", text: output || "(no output)" }],
};
} catch (error: any) {
let diagnostic = "Command failed";
if (error.code === "ETIMEDOUT") diagnostic = "Timeout after 30s";
else if (error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
diagnostic = "Output exceeded 256KB";
else if (error.code) diagnostic = `Exit ${error.code}`;
else if (error.signal) diagnostic = `Killed by ${error.signal}`;
const output = bound(
`${diagnostic}\nstdout: ${error.stdout || ""}\nstderr: ${error.stderr || ""}`,
);
sessions.set(sessionId, {
output,
expires: Date.now() + SESSION_TTL_MS,
});
return {
isError: true,
content: [{ type: "text", text: output }],
};
}
},
);
server.registerResource(
"last-session-output",
"context://session/{sessionId}",
{
description: "Read the bounded output retained for a session.",
mimeType: "text/plain",
},
async (uri, variables) => {
const sessionId = String(variables.sessionId);
const entry = sessions.get(sessionId);
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: entry?.output ?? "No output stored for this session.",
},
],
};
},
);
const transport = new StdioServerTransport();
process.on("SIGINT", async () => {
console.error("Shutting down gracefully...");
await server.close();
process.exit(0);
});
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});
await server.connect(transport);
console.error("bounded-repo-tools MCP server started");
Install and run it with:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
REPO_ROOT=/absolute/path/to/allowed/root npx tsx src/server.ts
The command allowlist is intentional. A tutorial that accepts arbitrary shell strings may look convenient, but it creates an injection and permission boundary that becomes difficult to reason about once an agent can invoke it.
Claude Desktop stdio configuration
Compile or run the server through tsx. The configuration below uses an absolute working directory and does not rely on the GUI process inheriting the same shell environment as your terminal.
{
"mcpServers": {
"bounded-repo-tools": {
"command": "/absolute/path/to/node_modules/.bin/tsx",
"args": ["/absolute/path/to/project/src/server.ts"],
"env": {
"NODE_ENV": "production",
"REPO_ROOT": "/absolute/path/to/allowed/root"
}
}
}
}
If this server is used behind Context Mode, place the output reduction before persistence and before any provider routing decision. Persist summaries, identifiers, and failure diagnostics; do not persist every raw test log by default. The repository's session-memory behavior is most useful when stored context remains reusable without becoming another source of context-window pressure.
The failure mode that tutorials omit
My first integration test used a straightforward console.log(JSON.stringify(result)) pattern. It worked until a malformed subprocess response introduced an unhandled JSON-RPC parsing exception. The process exited silently because stdout and stderr were not being observed by the host. From the agent's perspective, the tool had simply disappeared.
Production behavior requires three habits:
- Never write logs to stdout; stdio is the protocol channel.
- Catch tool-level failures and return
isError: true. - Keep process-level diagnostics on stderr and supervise the child process.
A second failure appeared after the parser was fixed: large JSON payloads exhausted the model context window even though the MCP call itself succeeded. Bounded output is therefore a correctness feature, not an optimization.
For provider comparisons, I would keep the MCP server provider-neutral and benchmark the surrounding agent harness separately. When testing multi-provider fallback through relay gateways like https://b-lost.com/v1, compare token usage with and without server-side reduction. The meaningful metric is not only latency; it is useful agent work completed per context window.
Context Mode's architectural advantage is its placement in the path. It can enforce reduction and routing across the agent ecosystem without requiring every individual tool author to understand each provider's context limits. The trade-off is deliberate loss of raw detail. In production, that is usually preferable to preserving every byte and losing the conversation that needed it.
The hardest operational question
The dilemma I still wrestle with: how do you tune the truncation threshold when different models have wildly different context windows, and the same model can exhaust its budget mid-conversation even with bounded tools? A static 12KB limit works until an agent needs to cross-reference three bounded outputs in a single turn and suddenly you're back in quota hell.
Do you make truncation adaptive based on remaining context budget? Do you let the agent request verbosity levels? Do you accept that some tasks simply require multiple turns and teach agents to paginate their own investigations?
How are you handling this in production?
Top comments (0)