Skip to content

fix(mcp): per-project registries, registry-independent rendering, per-server ${VAR} handling - #193

Merged
stippi merged 7 commits into
mainfrom
fix/per-project-mcp-registries
Sep 1, 2026
Merged

fix(mcp): per-project registries, registry-independent rendering, per-server ${VAR} handling#193
stippi merged 7 commits into
mainfrom
fix/per-project-mcp-registries

Conversation

@stippi

@stippi stippi commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Follow-up to #189. With sessions running in parallel across projects, the single-slot registry cache and the registry broadcast caused real misbehavior; this PR makes registries per-project and makes session rendering independent of connection state.

Problems

  1. Rendering depended on random connection state. Recorded MCP tool executions were resolved through whichever registry an instance happened to hold. Starting a run in project A rewired every session's rendering to A's registry (broadcast in refresh_tool_registry), so a session viewed while another project's agent was running silently dropped its MCP tool calls. The same failure hit a session viewed in a second code-assistant instance that never launched (or never trusted) the producing servers.
  2. Single cache slot thrashed across projects. Every run start in a different project rebuilt the registry and the eviction pass killed the other project's local MCP servers — with two agents active in two projects, local servers bounced on every alternation.
  3. ${VAR} substitution was all-or-nothing. One unset variable failed the whole mcp-servers.json (nothing registered) or dropped the whole .mcp.json.

Changes

  • Registry-independent MCP rendering. McpToolOutput is self-describing; recorded mcp__* executions now always deserialize via mcp_client::deserialize_mcp_output (tools::mcp::deserialize_tool_execution), never through a registry lookup. A session renders identically wherever it is viewed. Only native tools still require a registry entry.
  • Per-project registry cache, lifetimes via ownership. ConfigToolRegistry keys registries by the included local-MCP directory (None = global-only). Cache and connection pool hold Weak references: a registry lives while a session instance or in-flight run holds it; connections live while some registry references them (dropping the last Arc terminates the server child). This removes the building_keys/evict_unreferenced machinery and answers the eviction question deterministically: a project's servers run while a session that uses them is open. Parallel sessions in different projects no longer invalidate each other or relaunch each other's servers.
  • No more broadcast. refresh_tool_registry assigns the fresh registry to the requesting session's instance only (and catches an instance up when the provider returns an already-cached Arc for a second session in the same project).
  • Per-server ${VAR} degradation. McpServersConfig::substitute_env_values drops only the servers whose variables cannot be resolved and returns them for the loaders to log; the other servers keep working. Applies to both mcp-servers.json and .mcp.json.
  • Docs. docs/project-scoped-mcp-servers.md reworked from a design note into feature documentation (format, merge, trust, toggles, lifecycle, rendering guarantee, known limitations); cross-referenced from mcp-client-mode.md and AGENTS.md.

Notes

  • Frontends and SessionService needed no changes — provider signature and RegistryRequest are unchanged.
  • A viewed-only session keeps the default registry; the project registry is resolved at its first agent run (viewing must not launch servers or prompt for trust). Rendering no longer depends on it.
  • Known follow-ups (documented): no UI to revoke persisted trust; per-session server toggles hide tools but don't prevent the server process from launching.
  • session_mode_returns_session_id_while_running (PTY execute_command) is flaky under full-suite load on main as well — unrelated to this branch.

McpToolOutput is self-describing, so a recorded MCP execution can be
deserialized without a live server or registry entry. Expose that as
deserialize_mcp_output() (McpTool::deserialize_output delegates to it)
plus an is_mcp_tool_name() predicate for the reserved mcp__ prefix.
Session rendering and agent session state resolved recorded tool
executions through whichever registry the instance happened to hold —
so a parallel session in another project (or a second code-assistant
instance viewing the session) silently dropped MCP tool calls whose
server the current registry had not connected.

MCP executions now always deserialize from their self-describing JSON
via deserialize_tool_execution(); only native tools still require a
registry entry. A session renders identically wherever it is viewed.
The registry cache held a single slot, so sessions running in different
projects at the same time invalidated each other on every run start:
each switch rebuilt the registry and the eviction pass shut down the
other project's local MCP servers.

The cache is now a map keyed by the included local-MCP directory
(None = global-only; requests without trusted local MCP normalize to
it), so each project's registry stays valid while others build. Cache
and connection pool hold Weak references: a registry lives exactly as
long as a session instance or in-flight run holds it, connections live
as long as some registry references them (dropping the last Arc
terminates the server child). This replaces the explicit
building_keys/evict_unreferenced machinery with ownership-driven
lifetimes and answers the previously open eviction question: a
project's servers run while a session that uses them is open.
refresh_tool_registry broadcast every fresh registry to all active
session instances, so starting a run in one project rewired every other
session's rendering to that project's registry — a session viewed while
another project's agent ran turns lost its own MCP tool executions.

The refresh now updates the manager-level registry (this run's working
registry and the default for not-yet-run instances) and the requesting
session's instance alone. Other sessions keep the registry of their own
last run; MCP executions render registry-independently anyway.
Rework the design note into feature documentation: .mcp.json format and
merge semantics, the trust flow, per-session server toggles, the
per-project registry cache with ownership-driven connection lifetimes,
the registry-independent rendering of recorded MCP executions, and the
known limitations. Cross-reference from mcp-client-mode.md and
AGENTS.md.
Variable substitution was all-or-nothing: one unset variable failed the
whole configuration — the global mcp-servers.json registered nothing,
and a project's .mcp.json was dropped entirely. A server missing its
secret cannot connect anyway, but it must not take the other servers
down with it.

McpServersConfig::substitute_env_values now substitutes per server,
drops the failing ones, and returns them as (name, error) for the
loaders to log.
@stippi
stippi merged commit 15dc8fb into main Sep 1, 2026
5 checks passed
@stippi
stippi deleted the fix/per-project-mcp-registries branch September 1, 2026 08:54
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