The MCP shim can carry a session's tool calls to a Collins that isn't wired up yet - #204
Conversation
… wired up yet PR 2 of the session-MCP-tools plan: collins/mcp_shim.py (the stdlib-only stdio MCP server claude will spawn, relaying tools/list and tools/call to the app over a Unix socket) and collins/mcptools.py (the GTK-free app-side layer: the TOOLS table serving only set_session_title so far, argument re-validation, wire framing with a 1 MiB guard, runtime-dir paths and the --mcp-config writer). Inert until PR 3 binds the socket service and appends the flag to launched commands. The shim never breaks a session: with Collins absent it still completes the MCP handshake, serves an empty tool list, and returns clean "Collins is not running" tool errors; a failed round trip drops the connection and the next request reconnects, which is what heals a Collins restart. Tests run the shim as a real subprocess against a threaded socketserver faking Collins: handshake transcript, hello pid, forwarding, degradation (env unset / socket absent / unresponsive server), reconnect after a fake restart, and a no-stray-stdout-bytes sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016J5wuRHXbUZraqsEApxRUn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016J5wuRHXbUZraqsEApxRUn
|
@claude review |
|
Claude finished @ghackett's task in 4m 15s —— View job Reviewing PR #204
SummaryThis is a clean, well-scoped PR — the shim/app split, the "never break the session" degradation story, and the test coverage (real subprocess vs. a threaded fake socket server) all match the description closely and hold up under a close read. I traced the diff against Confirmed by reading the code:
FindingsMinor — inconsistent shutdown handling on the parse-error path ( try:
msg = json.loads(line)
except ValueError:
_error(None, -32700, "Parse error") # not guarded
continue
try:
if isinstance(msg, dict):
_handle(link, msg)
else:
_error(None, -32600, "Invalid Request")
except BrokenPipeError:
return 0 # the CLI went away mid-replyThe Nits / observations (non-blocking)
Nothing here blocks merging — the one finding is a minor edge-case robustness gap, not a functional bug, and the test suite (which I read in full, not just skimmed) already exercises the degradation, reconnect, and stdout-hygiene scenarios thoroughly. I wasn't able to execute the tests in this sandbox (Python invocations here require approvals I don't have), consistent with the PR body's note that this machine has no pytest either — CI remains the runner of record. |
Review finding on the PR: the -32700 reply for malformed stdin wasn't
covered by the BrokenPipeError guard, so garbage arriving after the CLI
closed our stdout would traceback instead of exiting 0. The guard now
wraps every reply path, and the handler points stdout at devnull first so
the interpreter's exit-time flush of the half-written buffer can't raise
the same error again ("Exception ignored" noise on stderr).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016J5wuRHXbUZraqsEApxRUn
|
Addressed the finding in b933473: the whole per-line loop body now sits under the On the nits:
🤖 Generated with Claude Code |
PR 3 of the [session MCP tools plan](#201) (after #201's ancestry walk and #204's shim + protocol layer): the app-side socket service goes live, launched commands carry `--mcp-config`, and the first tool — `set_session_title` — works end to end through a real `claude` CLI. ## What's here - **`collins/mcpserver.py`** (new): `SessionToolService`, a `Gio.SocketService` on the per-instance Unix socket. Fully async on the GLib main loop — no threads; per connection the cycle is strictly read → reply → read, so a peer that floods requests without reading replies stalls only itself. Takes injected `list_tools()` / `dispatch(pid, tool, args)` callables, so the whole connection machinery is CI-testable without GTK. Untrusted-peer rules: a first frame that isn't a well-formed hello, or any framing violation, disconnects rather than guesses. - One hazard found live: **Gio silently truncates a socket path past `sun_path`'s 107 bytes**, producing a listener no shim can ever dial (the shim degrades cleanly, so the feature would just silently not work). `start()` now refuses over-long paths loudly; real paths under `$XDG_RUNTIME_DIR` sit far below the limit. - **`collins/providers.py`**: module-level `MCP_CONFIG_PATH` (set by app.py only once the whole chain is up) plus a `supports_mcp_config` capability flag mirroring `supports_fork`. New/resume/fork/continue commands and the chat argv all carry the flag; **the attach branch carries nothing by construction** (`claude attach` accepts no flags and joins a process that already has its servers) — tested. - **`collins/app.py`**: service bring-up in `do_startup` (one failure path: log, leave `MCP_CONFIG_PATH` unset, commands go out exactly as before — the tools are conveniences, never load-bearing), the codebase's first `do_shutdown` to tear it down, and the dispatcher: `/proc` ancestry walk from the shim's hello pid to the tab whose shell spawned its `claude`, argument re-validation app-side (the socket is reachable by any local process), and the `set_session_title` handler. - **`collins/window.py`**: `MainWindow.rename_session_tab()` — the rename dialog's save path without the dialog. The rename lands in the manual-name slot, so it permanently stops auto-titling for that session, exactly as a hand rename does. - **`collins/terminal.py`**: `TerminalTab.owns_pid_ancestors()` over `_candidate_pids()` (both the pty's foreground pgrp leader and the spawned child, per the daemon-wrapper note there). - **Tests**: 24 for the service (raw socket clients against a live GLib loop, hostile-peer cases, lifecycle) including two true end-to-end tests driving the real `mcp_shim` subprocess against the real service — the seam neither module's own tests crossed — plus 7 new provider tests for the flag. ## Verified live (headless, CLI 2.1.226) - A real `claude -p --mcp-config …` session listed the tool, called it through `--allowedTools`, and echoed the service's "Session renamed." reply; dispatch received the shim's pid. - A throwaway app instance (fresh `COLLINS_APP_ID`, isolated data): service up, runtime dir keyed by app id, typed resume command carries the flag, a client fed into the tab's own terminal renamed the session through the socket (screenshots below), and a client *not* descended from any tab got the clean identity error — the expected shape for daemon-hosted bg jobs until the identity fallback lands. - **PR 2's deferred question answered:** the CLI *merges* an stdio server's `env` block into the inherited environment (probe saw both the config var and a parent-env marker). Either behavior works for the shim, but now it's recorded. ## Screenshots Before: the tab's typed command carries `--mcp-config`. After: a client run from the tab's own shell called `set_session_title` — tab title and sidebar row both renamed, ok reply visible in the terminal. | Before | After | | --- | --- | | <img src="https://raw.githubusercontent.com/episode6/screenshots/main/collins/session-mcp-pr3/before-20260808.png" alt="tab open with --mcp-config in the typed resume command" /> | <img src="https://raw.githubusercontent.com/episode6/screenshots/main/collins/session-mcp-pr3/after-20260808.png" alt="tab and sidebar renamed by the MCP tool" /> | Per the spec, v1 pre-allows nothing — every first use goes through the CLI's own permission prompt. Next up: PR 4 (`open_in_editor` + `show_image`), PR 5 (`notify_user`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PR 2 of the session-MCP-tools plan (spec:
~/specs/collins/session-mcp-tools.md; PR 1 was the proctree upward-ancestry work in PR #201). Two new modules plus tests — deliberately inert: nothing spawns the shim or binds the socket until PR 3 wires the socket service into the app and appends--mcp-configto launched commands.collins/mcp_shim.py— the stdio MCP serverclaudewill spawninitialize(echoes the client's protocolVersion),notifications/initialized,ping,tools/list,tools/call; unknown method with an id →-32601. NomcpPyPI dependency, keeping the debian packaging dependency-free.collins— it runs as a child of the agent CLI, so a mismatched or half-upgraded install must not be able to break session startup. Shared wire constants are mirrored by hand, with comments pointing both ways.tools/list/tools/callto the app over the Unix socket named byCOLLINS_MCP_SOCKET; the tool list lives app-side only, so adding a tool never changes the shim. The first line on every connection is a hello carrying the shim's pid — the seed for the proctree ancestry walk that maps a call to its tab in PR 3.tools/listis empty, calls return a clean "Collins is not running" tool error. Timeouts (connect 1s, list 3s, call 15s) report "Collins did not respond in time". Any failure drops the connection and the next request redials — that lazy reconnect is what heals a Collins restart; there are no retry loops within a request.COLLINS_SHIM_LOGif set.collins/mcptools.py— the GTK-free app-side layerTOOLSserves onlyset_session_titlefor now — the list is app-served, and advertising tools whose handlers don't exist yet would invite calls that can only fail.open_in_editor,show_image, andnotify_userjoin in PRs 4–5.validate_args()re-validates every call app-side: the socket is reachable by any local process, so the CLI's schema enforcement is not a boundary.encode_message/decode_message) with a 1 MiB line guard; runtime-dir path derivation ($XDG_RUNTIME_DIR/collins/<app_id>/, keyed by app id so the real app keeps a stable path across restarts while debug instances stay isolated);write_config()writing the--mcp-configfile atomically (trust.py style, best-effort →Noneso callers skip the flag).proctree.py/chats.pysplit, so CI (no typelibs) tests the whole protocol.Tests
tests/test_mcp_shim.pyruns the shim exactly as the CLI will — a realpython -m collins.mcp_shimsubprocess — against a threaded socketserver faking Collins: handshake transcript, hello pid, list/call forwarding, app-side errors becoming tool errors, degradation (env unset / socket absent / unresponsive server → timeout error with the shim still healthy), reconnect across a fake Collins restart, and a sweep asserting stdout carries nothing but complete JSON-RPC lines. One subtlety: the fake server'sstop()must close established connections too, or its daemon handler threads keep serving a "dead" Collins.tests/test_mcptools.pycovers the table, validation, framing guards, paths, and the config writer.All scenarios were also hand-run green locally via a pytest-free harness (this machine has no pytest; CI is the runner of record).
No GPL modification notices needed: every file in this PR is new to the fork.
🤖 Generated with Claude Code
https://claude.ai/code/session_016J5wuRHXbUZraqsEApxRUn