0038. Explicit per-call agent targeting for forwarded MCP capability tools¶
- Status: accepted
- Date: 2026-07-22
- Amends: ADR-0033
Context and Problem Statement¶
Remote MCP servers get no per-conversation identifier from Claude clients (Claude Desktop,
claude.ai): the Mcp-Session-Id header the server hands back at init is not echoed on
follow-up requests. Every kenny capability tool (powershell_exec, diag_*, webfilter_*,
winget_*, remotehelp_*, screen_capture, net_*, fs_*) forwards to whatever agent was
last set as "active" by select_agent, and that active-agent slot was, until now, the only
notion of "which host does this call mean."
ADR-0033 made that slot per-caller (registry._active_by_key, keyed by
principal.active_key = session id / PAT id / OAuth token id) specifically to stop
concurrent operators from clobbering each other's selection. It closes the case where two
different credentials race. It does not close the case reported here: two concurrent Claude
sessions authenticated with the same credential (one user, two claude.ai tabs, or two
Claude Desktop windows sharing one PAT) resolve to the identical active_key, so they
share one slot. Confirmed in practice: select_agent("linus-pc") in session A, then
select_agent("bob-pc") in session B, then a forwarded call in session A — it silently runs
on bob-pc. No error, no signal; the wrong host just executes the command.
This is not a bug in the keying scheme; it's a ceiling on what keying by credential can ever guarantee, because the server has no signal that distinguishes the two conversations. Any routing decision that depends on shared state keyed only by what the client authenticated with is subject to the same collision.
The required outcome: a call meant for agent X always reaches X regardless of how many other sessions are doing things concurrently, and when the target can't be resolved unambiguously the call fails loudly rather than guessing.
Considered Options¶
- A — Require an explicit
agent_idargument on every forwarded MCP capability tool call. The server resolves the routing target from that argument alone; no target means an explicit error, never a fallback to shared state.select_agentstays as an advisory discovery/back-compat helper only. - B — Optional
agent_idwith a fallback to the existing sticky slot. Smaller surface change, more backward-compatible, but a call that omitsagent_idstill falls back to the shared, clobberable slot — the exact collision reported here survives for any caller (or any model turn) that forgets to pass it. Doesn't meet the "always X, or fails loudly" bar. - C — A finer sticky key, or a server-issued selection token echoed on every call. A finer
key is a dead end: the client gives no per-conversation signal to key on, so no scheme keyed
by credential can distinguish two sessions on that credential. A server-issued opaque token
the model must thread through every call is no more reliable than having it thread the agent
id it already knows (it read it from
list_agents/select_agent's result), and adds a token-lifecycle problem (issuance, expiry, revocation on scope change) for no extra safety — the scope check still has to happen at forward time either way.
Decision Outcome¶
Chosen option: A, applied differently on the two paths that forward capability tools, because only one of them is actually racy:
- MCP path (
kenny_server/tools.py, the reported bug).agent_idis required. The forwarder resolves the target as explicitagent_id→ else fail closed; it does not consult the per-principal sticky slot for routing at all. Two conversations sharing a credential can no longer collide, structurally — there is no shared state to race on.select_agentremains registered: it still validates an id and reports online state (useful for discovery), and the keyed-slot plumbing from ADR-0033 stays in the registry, but it no longer decides where a forwarded call lands. If a future client-side fix ever supplies a reliable per-conversation identifier, the safe keyed fallback can be re-enabled without another redesign. - Server-hosted dashboard chat (
kenny_server/chat.py). EachChatSessionis already a distinct, non-shared object — its ownagent_idfield genuinely identifies one conversation, unlike a credential-keyed slot. So the chat path keeps a sticky default (the session's selection) with a per-call override:_resolve_chat_targetprefers an explicitagent_idin the tool call's args, falls back tosession.agent_id, and still fails closed if neither is set. What changed is where that state lives: capability calls used to readregistry.require_active()— the same process-global slot shared by every concurrent chat session (a second, independently racy instance of the same bug class, not covered by ADR-0033 at all, since it never went through a key).run_capabilitynow takes the resolved target as an explicit argument, and the target is resolved and frozen once per tool call before the confirm-gate pause, so a dashboard agent switch mid-confirmation can't retarget an already-pending state-changing call (PendingCall.agent_idcarries the frozen value through to execution). - In both paths,
agent_idis routing metadata the server consumes and pops off the args dict before building the wirerequestframe — the agent never sees it. The wire contract is unaffected:Requestis{type, id, tool, args}withextra="forbid"(kenny_server/protocol.py), carries no agent identifier, and is routed purely by which WebSocket connection the server sends it down (tunnel.send_request(agent_id, ...)already tookagent_idas a plain Python argument, never part of the frame). NoPROTOCOL_VERSIONbump, no fixture change — only a clarifying note indocs/protocol.md. - The resolved target is always re-checked with the existing
_require_scope— an explicitagent_idis unvalidated client input, and a scopeduserprincipal must not be able to reach a host outside their assigned set just because they named it directly.
Consequences¶
- Good, because the race is now structurally impossible on the MCP path rather than merely narrowed: a forwarded call either names its host or is rejected before it ever reaches the tunnel. No sticky state is ever read for routing on that path.
- Good, because the fix is local and small: one resolver in
tools.py, one inchat.py, no changes to the wire contract, the registry's data model, or the Rust agent. - Good, because the two genuinely-safe forwarding paths that already passed an explicit
agent_idper call — every/api/agent/{id}/...and/api/agents/{id}/updatedashboard route, and thewebfilter_pushserver-only tool — needed no change at all; only the two paths that actually relied on shared/global state were touched. - Bad, because this is a breaking change to the MCP tool surface: every forwarded capability
tool call must now carry
agent_id. Existing callers that calledselect_agentthen a bare forwarded tool will get an explicitno_agenterror until they're updated to pass it — chosen deliberately over a silent optional fallback, since the whole point is to remove the possibility of a silent wrong-host call. Tool descriptions were updated to make the requirement discoverable to the model without a prompt change. - Neutral: the dashboard's copilot chat keeps an optional
agent_idoverride (not required) because its sticky default is per-session and therefore safe; only its storage moved from the shared registry slot to the session object.
More Information¶
- Supersedes ADR-0033's per-principal active-agent isolation as the routing mechanism for MCP
forwarding (ADR-0033's keyed slot and
select_agentremain, now advisory/back-compat only). - Implementation:
kenny_server/tools.py(_resolve_target, forwarder),kenny_server/chat.py(_resolve_chat_target,ChatExecutor.run_capability,_drive_events,_apply_confirmation,_select_agent),kenny_server/webui/__init__.py(chat routes stop writing the global registry slot). Tests:tests/test_tools.py(new — the two-caller race, direct and fixed),tests/test_chat.py(session-scoped routing, including two concurrent sessions sharing one executor/registry). docs/protocol.mdgained a prose note on the server-consumedagent_idrouting argument; no frame, fixture, orPROTOCOL_VERSIONchange.