Skip to content

The MCP shim can carry a session's tool calls to a Collins that isn't wired up yet - #204

Merged
ghackett merged 3 commits into
mainfrom
mcp-shim-protocol-layer
Aug 8, 2026
Merged

The MCP shim can carry a session's tool calls to a Collins that isn't wired up yet#204
ghackett merged 3 commits into
mainfrom
mcp-shim-protocol-layer

Conversation

@ghackett

@ghackett ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member

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-config to launched commands.

collins/mcp_shim.py — the stdio MCP server claude will spawn

  • Hand-rolled newline-delimited JSON-RPC 2.0: initialize (echoes the client's protocolVersion), notifications/initialized, ping, tools/list, tools/call; unknown method with an id → -32601. No mcp PyPI dependency, keeping the debian packaging dependency-free.
  • Stdlib-only and imports nothing from the rest of 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.
  • Relays tools/list/tools/call to the app over the Unix socket named by COLLINS_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.
  • Never breaks the session. Collins absent (quit, crashed, stale config): the handshake still succeeds, tools/list is 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.
  • Stdout carries protocol bytes only; debug logging goes to COLLINS_SHIM_LOG if set.

collins/mcptools.py — the GTK-free app-side layer

  • TOOLS serves only set_session_title for 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, and notify_user join 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.
  • Wire framing (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-config file atomically (trust.py style, best-effort → None so callers skip the flag).
  • GTK-free per the proctree.py/chats.py split, so CI (no typelibs) tests the whole protocol.

Tests

tests/test_mcp_shim.py runs the shim exactly as the CLI will — a real python -m collins.mcp_shim subprocess — 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's stop() must close established connections too, or its daemon handler threads keep serving a "dead" Collins. tests/test_mcptools.py covers 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

ghackett and others added 2 commits August 8, 2026 12:33
… 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
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @ghackett's task in 4m 15s —— View job


Reviewing PR #204

  • Gather context (diff, spec description, changed files)
  • Read collins/mcp_shim.py
  • Read collins/mcptools.py
  • Read tests/test_mcp_shim.py and tests/test_mcptools.py
  • Post review feedback

Summary

This 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 origin/main via git diff 6c3cb64..HEAD (the PR's actual base) since the checkout's local main ref is stale relative to origin/main; the four files match the stated +1006/-0.

Confirmed by reading the code:

  • Stdlib-only, zero imports from the rest of collins in mcp_shim.py — verified.
  • _MAX_LINE (shim) and MAX_LINE (mcptools) are both 1024 * 1024, kept in sync as the comments promise.
  • Timeouts match the description exactly: connect 1s, list 3s, call 15s.
  • Degradation paths (_AppUnavailable → empty tool list / "Collins is not running"; _AppTimeout → "Collins did not respond in time") are handled distinctly and correctly at each call site.
  • write_config's atomic tmp-file-then-replace and 0o700 runtime dir match the trust.py-style best-effort approach described.
  • No modified pre-fork files in this diff, so the GPL notice requirement doesn't apply here, as the PR body notes.

Findings

Minor — inconsistent shutdown handling on the parse-error path (collins/mcp_shim.py, main(), ~lines 253–264)

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-reply

The except BrokenPipeError: return 0 only wraps the second block. If the agent CLI has already closed the shim's stdout (process exiting) and a malformed line arrives on stdin in that window, _error(None, -32700, ...) will raise an uncaught BrokenPipeError and crash the shim with a traceback instead of exiting cleanly like every other shutdown path documented in this file ("Never break the session"). Low-probability race, but easy to close by wrapping both write paths (or the whole per-line loop body) in the same try/except BrokenPipeError.

Fix this →

Nits / observations (non-blocking)

  • _AppLink guards _sock/_reader with a threading.Lock, but main()'s stdin loop is strictly single-threaded — one message is fully handled before the next readline() — so the lock never sees contention today. Fine to keep as defensive plumbing if a future PR adds concurrent request handling, just noting it's currently inert.
  • write_config's env dict fully replaces the MCP subprocess's environment for the mcpServers.collins entry (only COLLINS_MCP_SOCKET and PYTHONPATH are set). Since command is sys.executable (absolute path) and the shim hardcodes encoding="utf-8" everywhere, this looks safe, but it's worth double-checking against how the actual claude CLI treats a stdio server's env (merge vs. replace) once PR 3 wires this up for real.

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.
· branch

@ghackett
ghackett marked this pull request as ready for review August 8, 2026 16:44
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
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Addressed the finding in b933473: the whole per-line loop body now sits under the BrokenPipeError guard, so the parse-error reply shares the clean-exit path. The handler also points stdout at devnull before returning — without that, the interpreter's exit-time flush of the half-written buffer raised the same error again and left "Exception ignored" noise on stderr. Added test_broken_stdout_still_exits_cleanly, which closes the read end before sending garbage and fails against the old code.

On the nits:

  • The _AppLink lock stays as deliberate plumbing: the wire protocol relies on one in-flight round trip at a time (replies correlate by id against ordered reads), and the lock is what makes that invariant hold if anything ever drives the link off the stdin thread. Documented in the class docstring.
  • Agreed on env merge-vs-replace being the open question — the shim only needs its two vars plus an inherited-or-not PATH-free environment (absolute sys.executable, -m import via PYTHONPATH), so either CLI behavior works. PR 3's manual verification (/mcp listing, live tool call) will confirm it against the real CLI either way.

🤖 Generated with Claude Code

https://claude.ai/code/session_016J5wuRHXbUZraqsEApxRUn

@ghackett
ghackett merged commit 8960c0f into main Aug 8, 2026
2 checks passed
@ghackett
ghackett deleted the mcp-shim-protocol-layer branch August 8, 2026 17:04
ghackett added a commit that referenced this pull request Aug 8, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant