iOfficeAI / iOfficeAI/OfficeCLI
batch's stdin peek swallows the next JSON-RPC frame in `officecli mcp` (affects --commands / --input too)
- Dominant language
- C#
- Stars
- 30.7k
- Forks
- 2.1k
- Avg merge
- 9d 8h
- Merged PRs (30d)
- 5
Description
## 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 ` 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:
```python
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 ` 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:
```csharp
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.
Contributor guide
Research direction
Start in src/officecli/CommandBuilder.Batch.cs at lines 189-209 and inspect how the shared LazyStdIn reader is used in mcp mode. Reproduce with the Python stdio client from the issue, then verify that --commands and --input do not probe transport stdin, and that argument-less batch fails clearly in mcp mode without swallowing JSON-RPC frames.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- api, cli
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100