#7116·CopilotKit

v1 CopilotRuntime resolves agents once, so MCP clients and dynamic actions cannot be per-request

Author: BenTaylorDevCreated Sep 14, 2026Updated Sep 15, 2026
Labelsbugmcp

Summary

The v1 CopilotRuntime shim resolves its agents once and bakes the resulting tools onto the agent instances. The v2 runtime has supported a per-request agent factory since #2941 (closed as completed) — resolveAgents(agents, request) in packages/runtime/src/v2/runtime/core/runtime.ts:139 calls agents({ request }) on every request, and cloneAgentForRequest clones the result per request. The v1 shim does not use it.

handleServiceAdapter instead assigns a resolved-once promise:

typescript
// packages/runtime/src/v1-deprecated/lib/runtime/copilot-runtime.ts:571
this.runtimeArgs.agents = Promise.resolve(this.runtimeArgs.agents ?? {}).then(async (agents) => { ... });

Three user-visible consequences follow from that one decision.

1. MCP clients are cached by URL, never keyed by credential, never closed

This is exactly what #2407 reports: the cache is indexed by endpointUrl only, so two users with different API keys share one client, and the reporter's workaround (a ?uid=<hash> suffix) leaks a client per key. MCPClient.close?() is declared in mcp-tools-utils.ts:80 and is never called anywhere in v1-deprecated.

Measured on one runtime instance, two endpoint constructions: clientsCreated: 1, closed: 0.

2. A dynamic actions function never sees request context

ActionsConfiguration accepts ((ctx: { properties: any; url?: string }) => Action<T>[]), but getToolsFromActions is only ever called at resolution time and invokes it as:

{ properties: {}, url: undefined }

Measured — that is the literal value received. The action list is also frozen at resolution, so it could not vary per request even with correct properties. Request properties do reach the runtime elsewhere (body.forwardedProps, used by the before-request hook at copilot-runtime.ts:784); they just never reach the actions resolver.

3. No per-run client lifecycle

Two deployment shapes, two failure modes:

  • Module-scope runtime (the documented pattern): one client for the life of the process. If the transport drops, tool.execute fails forever — no reconnect, no invalidation.
  • Per-request runtime (e.g. examples/showcases/open-mcp-client/apps/web/app/api/copilotkit/route.ts:578-604 builds new CopilotRuntime inside export const POST): a new cache and a new client per HTTP request, never closed.

Why now

None of this mattered while the tools were no-ops. #6931 restores execution — action.handler and MCPTool.execute are now actually called — so these become live reliability characteristics of a feature people will rely on.

Proposed shape

Route the v1 shim through the factory that #2941 already delivered:

  1. handleServiceAdapter installs async ({ request }) => … instead of a resolved-once promise.
  2. Clone each agent inside the factory, so per-request tools cannot leak across concurrent requests.
  3. Parse forwardedProps and runId from the request body; pass properties to getToolsFromActions and to getToolsFromMCP — whose options?: { properties } parameter already exists and is currently dead code, with no caller.
  4. Key MCP clients per run rather than per endpoint URL.
  5. Close them in the after-request hook. callAfterRequestMiddleware derives runId (v2/runtime/core/middleware.ts:101) and the v1 shim always installs an afterRequestMiddleware (copilot-runtime.ts:502), so the correlation point exists.

Open question that blocks step 5

callAfterRequestMiddleware is fire-and-forget and consumes a cloned SSE stream. Does it fire when a client disconnects mid-run? If it does not, opening a client per run without a guaranteed close is strictly worse than today, where the instance cache holds exactly one. This needs a real streaming test before any of steps 1-4 are written.

Also worth folding in

#6931 makes tool attachment dedupe by name, so two MCP servers exposing the same tool name now silently attach only the first (previously both attached, duplicated). Neither behaviour is right — #2409 asks for server-name prefixing, and this is the natural place to do it.

Refs

  • #2407 — dynamic MCP API keys + cache leak (the user-facing report of consequence 1)
  • #2409 — duplicate MCP tool names should be prefixed
  • #2941 — per-request agent factory, delivered in v2; this issue is about the v1 shim adopting it
  • #6408 — v1.50.0 orphan tracker
  • #6931 — restores execution, which is what makes this matter