Skip to content

batch's stdin peek swallows the next JSON-RPC frame in officecli mcp (affects --commands / --input too) #340

Description

@forrestlinfeng

Summary

In officecli mcp, the batch command's stdin probe reads from the same pipe the
MCP server parses JSON-RPC from. The abandoned probe thread swallows the next
request frame that arrives on the connection, so that request is never seen by the
server: no response, no error, and notifications/cancelled is not acknowledged
either. The client can only give up on its own timeout.

The probe fires regardless of where the batch payload came from--commands
and --input <file> are affected exactly like the stdin form.

Environment

  • OfficeCLI 1.0.144 (officecli-mac-arm64, sha256 04757163…bd45, matching the release SHA256SUMS)
  • macOS 15 arm64; reproduced through a plain stdio MCP client
  • Originally observed on Windows 11 via an agent host, same signature

Reproduction

A ~40-line stdio client. It runs create, then one batch --input, then fires
three identical view calls back to back:

import json, subprocess, threading, queue, time, os, tempfile

BIN = "./officecli"                      # v1.0.144
work = tempfile.mkdtemp(); deck = os.path.join(work, "deck.pptx")
recipe = os.path.join(work, "recipe.json")
open(recipe, "w").write(json.dumps([{"command": "add", "parent": "/", "type": "slide"}]))

p = subprocess.Popen([BIN, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                     stderr=subprocess.DEVNULL, text=True, bufsize=1, cwd=work,
                     env=dict(os.environ, OFFICECLI_SKIP_UPDATE="1"))
q = queue.Queue()
threading.Thread(target=lambda: [q.put(l.strip()) for l in p.stdout], daemon=True).start()
send = lambda o: (p.stdin.write(json.dumps(o) + "\n"), p.stdin.flush())
def call(cid, cmd):
    send({"jsonrpc": "2.0", "id": cid, "method": "tools/call",
          "params": {"name": "officecli", "arguments": {"command": cmd}}})
seen = set()
def await_ids(ids, t):
    end = time.monotonic() + t
    while not ids <= seen and time.monotonic() < end:
        try: m = json.loads(q.get(timeout=0.2))
        except Exception: continue
        if "id" in m: seen.add(m["id"])
    return ids <= seen

send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
      "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "probe", "version": "0"}}})
await_ids({1}, 20); send({"jsonrpc": "2.0", "method": "notifications/initialized"})
call(2, ["create", deck]); await_ids({2}, 30)
call(3, ["batch", deck, "--input", recipe]); print("batch:", "ok" if await_ids({3}, 30) else "TIMEOUT")
for cid in (4, 5, 6):
    call(cid, ["view", deck, "outline"])
await_ids({4, 5, 6}, 15)                 # collect whatever comes back
for cid in (4, 5, 6):
    print(f"id={cid}:", "replied" if cid in seen else "NO RESPONSE")
p.kill()

Actual

batch: ok
id=4: NO RESPONSE      <- swallowed
id=5: replied
id=6: replied

The frame that follows the batch is lost; the connection then behaves normally
again. Also observed:

  • Same result with --commands '[…]' instead of --input — the payload source
    does not matter.
  • Same result with OFFICECLI_NO_AUTO_RESIDENT=1, so the resident is not involved.
  • A control session that applies the same edits with individual add calls and
    no batch never loses a frame.
  • The loss is not time-bounded. In the field the next request was sent 18 seconds
    after the batch replied and was still swallowed.
  • batch <file> with no --commands/--input is the extreme form: it falls back
    to reading stdin, so it waits for an EOF that never comes on an MCP connection.
    Anything the client writes meanwhile is consumed as batch input — in my run a
    JSON-RPC frame was parsed as a batch array and executed, printing
    [1] Added slide at /slide[1].
  • notifications/cancelled for the lost request is never acknowledged, which is
    consistent with the server never having seen the request at all.

Expected

A tools/call on an MCP connection is answered, or fails with an error. It should
not disappear, and MCP traffic should never be interpretable as batch input.

Root cause (as far as I can read it)

src/officecli/CommandBuilder.Batch.cs L189-L209:

bool stdinHasInput = Console.IsInputRedirected;
if (stdinHasInput)
{
    // The possibly-blocked Peek thread is abandoned; the process
    // exits normally.
    var stdinPeek = System.Threading.Tasks.Task.Run(() =>
    {
        try { return StdIn.Peek() != -1; }
        catch { return false; }
    });
    stdinHasInput = stdinPeek.Wait(TimeSpan.FromMilliseconds(50)) && stdinPeek.Result;
}

Two assumptions hold for a one-shot CLI run and break in mcp mode:

  1. "the process exits normally" — the MCP server does not exit. The abandoned
    thread stays blocked on the read, and whenever the next bytes arrive it wakes up
    and consumes them.
  2. StdIn is the shared LazyStdIn reader (L686), and StreamReader.Peek() fills
    that reader's internal buffer — so it does not take one character, it takes the
    whole block that was available. The MCP server parses from its own reader over
    the same stdin, so those bytes are simply gone.

The probe also runs when --commands / --input already supplied the payload; there
it exists only to print the "stdin will be ignored" warning, at the cost above.

Suggested fix

  • Skip the stdin probe entirely when the payload came from --commands or --input
    (the warning is worth much less than a lost request), and skip it in mcp mode
    in any case — stdin belongs to the transport there.
  • In mcp mode, make an argument-less batch fail fast with a clear error instead
    of falling back to reading the transport's stdin.
  • Unrelated but adjacent: implementing notifications/cancelled would let a client
    recover from a stuck call instead of holding a dead connection.

Happy to test a patch on macOS and Windows.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions