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:
- "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.
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.
Summary
In
officecli mcp, thebatchcommand's stdin probe reads from the same pipe theMCP 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/cancelledis not acknowledgedeither. The client can only give up on its own timeout.
The probe fires regardless of where the batch payload came from —
--commandsand
--input <file>are affected exactly like the stdin form.Environment
1.0.144(officecli-mac-arm64, sha25604757163…bd45, matching the releaseSHA256SUMS)Reproduction
A ~40-line stdio client. It runs
create, then onebatch --input, then firesthree identical
viewcalls back to back:Actual
The frame that follows the batch is lost; the connection then behaves normally
again. Also observed:
--commands '[…]'instead of--input— the payload sourcedoes not matter.
OFFICECLI_NO_AUTO_RESIDENT=1, so the resident is not involved.addcalls andno
batchnever loses a frame.after the batch replied and was still swallowed.
batch <file>with no--commands/--inputis the extreme form: it falls backto 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/cancelledfor the lost request is never acknowledged, which isconsistent with the server never having seen the request at all.
Expected
A
tools/callon an MCP connection is answered, or fails with an error. It shouldnot disappear, and MCP traffic should never be interpretable as batch input.
Root cause (as far as I can read it)
src/officecli/CommandBuilder.Batch.csL189-L209:Two assumptions hold for a one-shot CLI run and break in
mcpmode:thread stays blocked on the read, and whenever the next bytes arrive it wakes up
and consumes them.
StdInis the sharedLazyStdInreader (L686), andStreamReader.Peek()fillsthat 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/--inputalready supplied the payload; thereit exists only to print the "stdin will be ignored" warning, at the cost above.
Suggested fix
--commandsor--input(the warning is worth much less than a lost request), and skip it in
mcpmodein any case — stdin belongs to the transport there.
mcpmode, make an argument-lessbatchfail fast with a clear error insteadof falling back to reading the transport's stdin.
notifications/cancelledwould let a clientrecover from a stuck call instead of holding a dead connection.
Happy to test a patch on macOS and Windows.