D737 — A workspace binding scopes the tools through a local stdio bridge¶
Decision. A provider with at least one workspace binding gets, as its global MCP entry,
cartographer mcp --provider <p> (a stdio command) instead of the HTTP URL. The bridge resolves its
working directory (os.Getwd, never $PWD) with the binding table (clientconfig.ResolveWorkspace,
deepest binding wins) and proxies the session to the routed endpoint with ?kbs=<set>, which the
server already enforces. A folder under no binding keeps the provider's default set; an empty set
(--no-kb, or every bound KB unmounted) is answered locally as a session with no tools and never
sent as an empty kbs=; a client that declares roots while the folder is unbound is scoped by the
union of the bindings its roots resolve to. A provider with no binding keeps its HTTP entry.
Supersedes D717.
Why. D717 scoped the tools by writing a same-named project entry, which works only where a
project entry replaces the global one (Claude Code, from vendor docs), writes .mcp.json and its
siblings into the user's repositories, and needs a probe per client and release to stay true. What
every client does provide is the spawn directory of a stdio server (probe 2026-10-17: Claude Code,
Codex, Kiro CLI, OpenCode, Crush, Copilot CLI; OpenCode with $PWD set to $HOME). It costs a
process per session and cartographer on PATH.
Alternatives rejected. Flipping ProjectMCPShadowsGlobal per client after probes: still writes
into repositories and depends on each client's merge rules. Server-side roots over HTTP: the server
knows neither the machine's paths nor its bindings, and half the clients do not declare roots. A
${VAR} in the entry URL set by direnv: per-folder configuration and env expansion in every client.
Session-start hooks: they see the cwd but cannot restrict a tool call. A per-workspace token: the
hard boundary, left out of scope (D44/D45).
Consequences. ?kbs= is a scope filter, not a security boundary: the token in the session's
environment reaches every KB it allows. Nothing is written into a workspace for the tool scope;
PruneServerEntry removes what D717 left, on every project projection. The scope is fixed at the
start of the session (roots are asked once, after initialized). The entry command is the one found
on PATH, never os.Executable() (a versioned Cellar path); Codex needs the token variable in
env_vars. Release impact: a provider with a binding changes its global entry from HTTP to a stdio
command on the first sync after the upgrade. Antigravity and Kiro IDE were not probed; a host that
spawns outside the workspace and declares no roots gets the default set.
Limits. When the folder is unbound and the client declares roots, a server cannot ask for them
before it has answered initialize, so the bridge answers that handshake itself with instructions
that name no KB, then asks once after initialized; the tool list that follows carries the real set.
The bridge does not open the optional GET stream, so server messages outside a POST response are
lost (the routed server sends none); it sends a DELETE for a server-opened session on exit. Where
cartographer is not on PATH at sync time (a hook with a minimal PATH), the provider keeps its
HTTP entry and sync warns: its bindings do not narrow the tools until the next sync finds it.