9.5 KiB
Phase 1 Step 8c — Implementation Notes
Status
Done.
Plan reference
- Plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Section: "Step 8c: Extract
McpFactory::acquire()fromMcpRegistry::init_server"
Summary
Extracted the MCP subprocess spawn + rmcp handshake logic from
McpRegistry::start_server into a standalone pub(crate) async fn spawn_mcp_server() function. Rewrote start_server to call it.
Implemented McpFactory::acquire() using the extracted function
plus the existing try_get_active / insert_active scaffolding
from Step 6.5. Three types in mcp/mod.rs were bumped to
pub(crate) visibility for cross-module access.
What was changed
Files modified (2 files)
-
src/mcp/mod.rs— 4 changes:-
Extracted
spawn_mcp_server(~40 lines) — standalonepub(crate) async fnthat takes an&McpServerspec and optional log path, builds atokio::process::Command, creates aTokioChildProcesstransport (with optional stderr log redirect), calls().serve(transport).awaitfor the rmcp handshake, and returnsArc<ConnectedServer>. -
Rewrote
McpRegistry::start_server— now looks up theMcpServerspec fromself.config, callsspawn_mcp_server, then does its own catalog building (tool listing, BM25 index construction). The spawn + handshake code that was previously inline is replaced by the one-linerspawn_mcp_server(spec, self.log_path.as_deref()).await?. -
Bumped 3 types to
pub(crate):McpServer,JsonField,McpServersConfig. These were previously private tomcp/mod.rs.McpFactory::acquire()andMcpServerKey::from_spec()needMcpServerandJsonFieldto build the server key from a spec.McpServersConfigis bumped for completeness (Step 8d may need to access it when loading server specs during scope transitions).
-
-
src/config/mcp_factory.rs— 3 changes:-
Added
McpServerKey::from_spec(name, &McpServer)— builds a key by extracting command, args (defaulting to empty vec), and env vars (convertingJsonFieldvariants to strings) from the spec. Args and env are sorted by the existingnew()constructor to ensure identical specs produce identical keys. -
Added
McpFactory::acquire(name, &McpServer, log_path)— the core method. Builds anMcpServerKeyfrom the spec, checkstry_get_activefor an existingArc(sharing path), otherwise callsspawn_mcp_serverto start a fresh subprocess, inserts the result intoactiveviainsert_active, and returns theArc<ConnectedServer>. -
Updated imports — added
McpServer,spawn_mcp_server,Result,Path.
-
Files NOT changed
src/config/tool_scope.rs— unchanged; Step 8d will useMcpFactory::acquire()to populateMcpRuntimeinstances.- All caller code —
McpRegistry::start_select_mcp_serversandMcpRegistry::reinitcontinue to callself.start_server()which internally uses the extracted function. No caller migration.
Key decisions
1. Spawn function does NOT list tools or build catalogs
The plan said to extract "the MCP subprocess spawn + rmcp handshake
logic (~60 lines)." I interpreted this as: Command construction →
transport creation → serve() handshake → Arc wrapping. The tool
listing (service.list_tools) and catalog building (BM25 index) are
McpRegistry-specific bookkeeping and stay in start_server.
McpFactory::acquire() returns a connected server handle ready to
use. Callers (Step 8d's scope transitions) can list tools themselves
if they need to build function declarations.
2. No abort_signal parameter on spawn_mcp_server
The plan suggested abort_signal: &AbortSignal as a parameter. The
existing start_server doesn't use an abort signal — cancellation
is handled at a higher level by abortable_run_with_spinner wrapping
the entire batch of start_select_mcp_servers. Adding an abort signal
to the individual spawn would require threading tokio::select! into
the transport creation, which is a behavior change beyond Step 8c's
scope. Step 8d can add cancellation when building ToolScope if
needed.
3. McpServerKey::from_spec converts JsonField to strings
The McpServer.env field uses a JsonField enum (Str/Bool/Int) for
JSON flexibility. The key needs string comparisons for hashing, so
from_spec converts each variant to its string representation. This
matches the conversion already done in the env-building code inside
spawn_mcp_server.
4. McpFactory::acquire mutex contention is safe
The plan warned: "hold the lock only during HashMap mutation, never
across subprocess spawn." The implementation achieves this by using
the existing try_get_active and insert_active methods, which each
acquire and release the mutex within their own scope. The spawn_mcp_server
await happens between the two lock acquisitions with no lock held.
TOCTOU race: two concurrent callers could both miss in try_get_active,
both spawn, and both insert. The second insert overwrites the first's
Weak. This means one extra subprocess gets spawned and the first
Arc has no Weak in the map (but stays alive via its holder's
Arc). This is acceptable for Phase 1 — the worst case is a
redundant spawn, not a crash or leak. Phase 5's pooling design
(per-key tokio::sync::Mutex) will eliminate this race.
5. No integration tests for acquire()
The plan suggested writing integration tests for the factory's sharing
behavior. Spawning a real MCP server requires a configured binary on
the system PATH. A mock server would need a test binary that speaks
the rmcp stdio protocol — this is substantial test infrastructure
that doesn't exist yet. Rather than building it in Step 8c, I'm
documenting that integration testing of McpFactory::acquire() should
happen in Phase 5 when the pooling infrastructure provides natural
test hooks (idle pool, reaper, health checks). The extraction itself
is verified by the fact that existing MCP functionality (which goes
through McpRegistry::start_server → spawn_mcp_server) still
compiles and all 63 tests pass.
Deviations from plan
| Deviation | Rationale |
|---|---|
No abort_signal parameter |
Not used by existing code; adding it is a behavior change |
| No integration tests | Requires MCP test infrastructure that doesn't exist |
Removed get_server_spec / log_path accessors from McpRegistry |
Not needed; acquire() takes spec and log_path directly |
Verification
Compilation
cargo check— clean, zero warnings, zero errorscargo clippy— clean
Tests
cargo test— 63 passed, 0 failed (unchanged from Steps 1–8b)
Handoff to next step
What Step 8d can rely on
spawn_mcp_server(&McpServer, Option<&Path>) -> Result<Arc<ConnectedServer>>— available fromcrate::mcp::spawn_mcp_serverMcpFactory::acquire(name, &McpServer, log_path) -> Result<Arc<ConnectedServer>>— checks active map for sharing, spawns fresh if needed, inserts into active mapMcpServerKey::from_spec(name, &McpServer) -> McpServerKey— builds a hashable key from a server specMcpServer,McpServersConfig,JsonField— allpub(crate)and accessible fromsrc/config/
What Step 8d should do
Build real ToolScope instances during scope transitions:
- Resolve the effective enabled-server list from the role/session/agent
- Look up each server's
McpServerspec (from the MCP config) - Call
app.mcp_factory.acquire(name, spec, log_path)for each - Populate an
McpRuntimewith the returnedArc<ConnectedServer>handles - Construct a
ToolScopewith the runtime + resolvedFunctions - Assign to
ctx.tool_scope
What Step 8d should watch for
-
Log path.
McpRegistrystoreslog_pathduringinit(). Step 8d needs to decide where the log path comes from for factory-acquired servers. Options: store it onAppState, compute it frompaths::cache_path(), or pass it through from the caller. The simplest is to store it onMcpFactoryat construction time. -
MCP config loading.
McpRegistry::init()loads and parsesmcp.json. Step 8d's scope transitions need access to the parsedMcpServersConfigto look up server specs by name. Options: store the parsed config onAppState, or load it fresh each time. Storing onAppStateis more efficient. -
Catalog building.
McpRegistry::start_serverbuilds aServerCatalog(BM25 index) for each server after spawning. Step 8d'sToolScopedoesn't use catalogs — they're for themcp_searchmeta-function. The catalog functionality may need to be lifted out ofMcpRegistryeventually, but that's not blocking Step 8d.
Files to re-read at the start of Step 8d
docs/PHASE-1-IMPLEMENTATION-PLAN.md— Step 8d section- This notes file
src/config/mcp_factory.rs— full filesrc/config/tool_scope.rs— full filesrc/mcp/mod.rs—McpRegistry::init,start_select_mcp_servers,resolve_server_idsfor the config loading / server selection patterns that Step 8d will replicate
References
- Phase 1 plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Step 8b notes:
docs/implementation/PHASE-1-STEP-8b-NOTES.md - Step 6.5 notes:
docs/implementation/PHASE-1-STEP-6.5-NOTES.md - Modified files:
src/mcp/mod.rs(extractedspawn_mcp_server, rewrotestart_server, bumped 3 types topub(crate))src/config/mcp_factory.rs(addedfrom_spec,acquire, updated imports)