Skip to content

Commit 2b2ca3c

Browse files
bgregoirclaude
andcommitted
[llm] mcp -sessions: one engine per agent behind one MCP server
`easycrypt mcp' is one engine with one proof state. An MCP client such as Claude Code opens one stdio connection per configured server and lets every agent it runs share it, and the transport carries no caller identity, so two agents driving the server clobber each other's goals. The only fix is at the protocol level: a `session' argument on every tool, and one engine per session. Not several EcLlmCore states in one process. The engine is single-threaded, so one agent loading a large development would block every other session; the loader cache, the prover configuration and the Why3 processes are global; and a loaded engine holds memory that only a process exit gives back. `mcp -sessions' therefore runs a multiplexer instead of an engine: the first call naming a session starts a child `easycrypt mcp' -- the single-engine server, unchanged, launched from our own executable with our own arguments minus the multiplexer's -- and every later call naming it is forwarded there. The child's result or error comes back under the client's id. `initialize', `ping' and `tools/list' are answered locally, under the same server name, so a client's approval of the server carries over. Requests run concurrently, one thread each; the threads do I/O and JSON and nothing else. Three mutexes: one per child (a child is synchronous, so calls to one session serialise), one for the client's stdout, one for the session table. A new session's handshake runs under its own lock, not the table's, so a second caller on that name queues behind it rather than stalling everyone. A child that dies under a call is dropped and the caller told so in a tool-level error that says to `ec_load' again; a name closed and re-created while a call on the old child was in flight is not taken down by that call. Children must not outlive the multiplexer: `at_exit' and handlers for SIGTERM, SIGINT and SIGHUP kill them, and under SIGKILL they read EOF on their stdin and stop once their current command completes. SIGPIPE is ignored, so a write to a dead child is an EPIPE we can report on that session rather than a signal that takes the server down. Exited children are reaped. Session names double as log-file names, hence a restricted alphabet. An idle reaper kills sessions unused for `-idle' minutes (default 180) that are not mid-call. Two tools of the multiplexer's own, `ec_sessions' and `ec_close'. The tool table with `session' added is a pure function of the existing one and lives next to it, so the two cannot drift apart. `Wire', the initialize reply and the stdout dup trick move to module level in EcMcp so both servers share them; `wire_stdout' now dups close-on-exec, so no process we start holds the client's pipe. The single-engine server is otherwise untouched: the 18 MCP goldens, the parity check and the 38 REPL goldens pass unchanged. `scripts/testing/mcp-sessions' checks the contract, and `make test-mcp' runs it. It is not a golden: the multiplexer answers in whatever order the children finish, and pids and idle times are not reproducible. It drives the server as a client would and asserts the tool table, two sessions loading different files at once without seeing each other, tool-level errors for a missing or invalid name against pass-through protocol errors from the engine, ec_sessions/ec_close, an engine that exited being reported dead and restarted by the next call, and no child surviving end of input or SIGTERM. doc/llm/CLAUDE.md gains "Multi-agent sessions" -- what `mcp -help' prints -- with the rule for agents: one session name per agent, never another's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkdycFar1eka8ReJ9HeqZf
1 parent 26de277 commit 2b2ca3c

13 files changed

Lines changed: 1120 additions & 86 deletions

File tree

‎Makefile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ LLMWARM += --bin=./ec.native
1818
MCPCHECK := scripts/testing/mcp-golden
1919
MCPCHECK += --bin=./ec.native
2020
MCPPARITY := scripts/testing/mcp-parity
21+
MCPSESSIONS := scripts/testing/mcp-sessions
2122
MCPPARITY += --bin=./ec.native
2223
NIX ?= nix --extra-experimental-features "nix-command flakes"
2324
PROFILE ?= dev
@@ -65,6 +66,7 @@ test-llm: build
6566
test-mcp: build
6667
$(MCPCHECK)
6768
$(MCPPARITY)
69+
$(MCPSESSIONS)
6870

6971
check: unit stdlib examples test-llm test-mcp
7072
@true

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ Besides these, EasyCrypt ships an interface aimed at LLM agents rather
170170
than at humans: `easycrypt llm`, an interactive REPL speaking a
171171
machine-friendly protocol, and `easycrypt mcp`, a
172172
[Model Context Protocol](https://modelcontextprotocol.io/) server over
173-
stdio. Both drive the same proof engine, and both are documented in
173+
stdio (`easycrypt mcp -sessions` serves one engine per named session,
174+
for clients that run several agents at once). Both drive the same
175+
proof engine, and both are documented in
174176
[doc/llm/CLAUDE.md](doc/llm/CLAUDE.md).
175177

176178
### Proof-General (Emacs)

‎doc/llm/CLAUDE.md‎

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -457,11 +457,14 @@ protocol, as a debug console, or for `-eval` scripting.
457457

458458
```
459459
easycrypt mcp [OPTIONS]
460+
easycrypt mcp -sessions [-idle <minutes>] [-logdir <dir>] [OPTIONS]
460461
```
461462

462-
The same loader and prover options as `llm` are available (`-I`,
463-
`-timeout`, `-p`, `-stdlib`, etc.). Use `-help` to print this section
464-
and exit:
463+
The first form is one engine for one client; the second, one engine
464+
per named session behind one server, for clients that run several
465+
agents at once (see "Multi-agent sessions" below). The same loader
466+
and prover options as `llm` are available in both (`-I`, `-timeout`,
467+
`-p`, `-stdlib`, etc.). Use `-help` to print this section and exit:
465468

466469
```
467470
easycrypt mcp -help
@@ -477,7 +480,9 @@ resources, no prompts, no sampling.
477480
### Tools
478481

479482
Thirteen tools. Required arguments are marked; the others default as
480-
noted.
483+
noted. Under `-sessions`, every one of them takes a required `session`
484+
as well, and two more tools appear, `ec_sessions` and `ec_close`; see
485+
"Multi-agent sessions" below.
481486

482487
| Tool | Arguments | Description |
483488
|------|-----------|-------------|
@@ -530,8 +535,9 @@ under both, and is recorded for `ec_commit` in both.
530535
### State and uuids
531536

532537
The state model is the REPL's, unchanged. One client is one process is
533-
one engine state: there is no multiplexing, and tool calls run strictly
534-
in arrival order even when a client pipelines them. Every result
538+
one engine state: tool calls run strictly in arrival order even when a
539+
client pipelines them. Several agents sharing one client need one
540+
engine each; that is what `-sessions` provides (see below). Every result
535541
reports in its `structuredContent` the `uuid` the call left behind —
536542
the same monotonically increasing state identifier the REPL prints as
537543
`[uuid:N]`, advancing only on calls that change engine state — and
@@ -601,6 +607,67 @@ Add loader options to `args` as needed, e.g. `["mcp", "-I",
601607
claude mcp add easycrypt -- easycrypt mcp
602608
```
603609

610+
### Multi-agent sessions
611+
612+
An MCP client such as Claude Code opens one connection per configured
613+
server and lets every agent it runs share it, and the stdio transport
614+
carries no caller identity. Two agents driving one `easycrypt mcp`
615+
therefore clobber each other's goals. The fix is at the protocol
616+
level:
617+
618+
```
619+
easycrypt mcp -sessions [-idle <minutes>] [-logdir <dir>]
620+
```
621+
622+
runs a *multiplexer* instead of an engine: the same tools, each with
623+
one more required argument, `session`, naming the engine the call
624+
runs in. The first call naming a session starts a child
625+
`easycrypt mcp` — the single-engine server above, with the loader and
626+
prover options the multiplexer received — and every later call naming
627+
it is forwarded there. Sessions are independent processes: their own
628+
loaded file, uuids, checkpoints, strict mode and memory. Calls to
629+
different sessions run in parallel; calls to the same session run in
630+
arrival order, as before. `initialize`, `tools/list` and `ping` are
631+
answered by the multiplexer itself, under the same server name, so a
632+
client's approval of the server carries over.
633+
634+
**The rule for agents: one session name per agent, and never another
635+
agent's.** Use your agent tag, or any name that is yours alone, in
636+
every call. A call without `session` is refused with a tool-level
637+
error, as is a name that is not made of letters, digits, `_`, `-` and
638+
`.` (at most 64 characters). Two agents that share a name share an
639+
engine and are back to clobbering each other.
640+
641+
Two tools belong to the multiplexer:
642+
643+
| Tool | Arguments | Description |
644+
|------|-----------|-------------|
645+
| `ec_sessions` | — | List the live sessions, one line each: `NAME pid PID idle Ns`, with `(dead)` appended when the engine has exited; or `no live session` |
646+
| `ec_close` | `session` (req) | Kill that session's engine and forget it; answers `closed NAME` or ``no session `NAME'`` |
647+
648+
A session unused for `-idle` minutes (default 180) is killed, unless a
649+
call is running in it; a loaded large development is a lot of resident
650+
memory, and only a process exit gives it back. Close your own session
651+
with `ec_close` when you are done with it. A session whose engine died
652+
— killed, timed out, or stopped by an `exit.` phrase — is dropped, and
653+
the next call naming it starts a fresh engine, which needs an
654+
`ec_load` again; a call that finds the engine gone says so in a
655+
tool-level error rather than failing silently.
656+
657+
Each child's stderr — the engine's own chatter, which the single
658+
server also writes to stderr — goes to `<dir>/ec-mcp-<session>.log`,
659+
with `-logdir` defaulting to `$TMPDIR`, else `/tmp`. The
660+
multiplexer's stdout carries the protocol and nothing else. When the
661+
client closes the connection, or the multiplexer is terminated, every
662+
child is killed with it.
663+
664+
A ready-to-use client configuration:
665+
666+
```json
667+
{"mcpServers": {"easycrypt": {"command": "easycrypt",
668+
"args": ["mcp", "-sessions"]}}}
669+
```
670+
604671
## EasyCrypt proof strategy
605672

606673
### General approach

0 commit comments

Comments
 (0)