Skip to content

mcp: bundle truncation drops subsequent ops without telling the caller #413

@justrach

Description

@justrach

Problem

handleBundle in src/mcp.zig has a 200KB output cap. When appending sub_out would exceed that cap, the handler emits a TRUNCATED entry for the offending op and then breaks out of the for-loop — silently abandoning every op after that index.

Concretely, a bundle of three reads — two large, one tiny — emits headers for [0] and [1], then breaks. Op [2], which the caller submitted, gets no header, no error, no acknowledgement that it was even received. The wire response is indistinguishable from a 2-op bundle. Any client that tries to correlate ops to results by index is going to mis-attribute output.

Failing Test

Branch: issue-413-failing-test

test "issue-413: bundle truncation drops subsequent ops without telling the caller" {
    var explorer = Explorer.init(testing.allocator);
    defer explorer.deinit();
    var store = Store.init(testing.allocator);
    defer store.deinit();
    var agents = AgentRegistry.init(testing.allocator);
    defer agents.deinit();
    _ = try agents.register("__filesystem__");
    var bench_ctx = mcp_mod.BenchContext.init(testing.allocator, ".");
    defer bench_ctx.deinit();

    var big: std.ArrayList(u8) = .empty;
    defer big.deinit(testing.allocator);
    while (big.items.len < 120 * 1024) {
        try big.appendSlice(testing.allocator, "pub fn placeholder() void { _ = 0; }\n");
    }
    try explorer.indexFile("big.zig", big.items);
    try explorer.indexFile("small.zig", "pub fn small() void {}\n");

    const bundle_json =
        \\{"ops":[
        \\  {"tool":"codedb_read","arguments":{"path":"big.zig"}},
        \\  {"tool":"codedb_read","arguments":{"path":"big.zig"}},
        \\  {"tool":"codedb_outline","arguments":{"path":"small.zig"}}
        \\]}
    ;
    const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, bundle_json, .{});
    defer parsed.deinit();
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(testing.allocator);
    bench_ctx.runDispatch(io, testing.allocator, .codedb_bundle, &parsed.value.object, &out, &store, &explorer, &agents);

    // op[2] (index 2) was sent — caller deserves to see something for it.
    try testing.expect(std.mem.indexOf(u8, out.items, "[2]") != null);
}

Expected

When truncation kicks in, the bundle response should still emit a --- [N] tool --- header for every remaining op, marked as DROPPED (or similar) so the caller can distinguish "ran but errored" from "never ran because we hit the cap".

Fix

In handleBundle (src/mcp.zig ~line 1764), after the truncation break, run a second pass that emits a placeholder entry for each remaining op:

if (out.items.len + sub_out.items.len > 200 * 1024) {
    w.print("--- [{d}] {s} ---\nTRUNCATED: ...\n", .{ i, tool_name }) catch {};
    fail_count += 1;
    // Still acknowledge the dropped ops so the caller can correlate.
    for (ops[i + 1 ..], i + 1..) |dropped_op, j| {
        const dt = if (dropped_op == .object) (getStr(&dropped_op.object, "tool") orelse "?") else "?";
        w.print("--- [{d}] {s} ---\nDROPPED: bundle truncated before this op ran\n", .{ j, dt }) catch {};
        fail_count += 1;
    }
    break;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p2Medium priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions