Skip to content

[per-component-executor] - Per-tool MCP execution over transport + production remote MCP host #1438

Description

@JoshuaRowePhantom

Per-tool MCP execution over transport + production remote MCP host

Part of design: #1432 — per-component-executor-binding
Design document: https://github.com/JoshuaRowePhantom/Phantom.Workspaces/blob/design/docs/design/per-component-executor-binding.md

Scope

Thread the resolved connection-descriptor (JsonElement) + a production ExecutorTargetRouter into each McpToolContextProvider (constructed in AgentChat). When the bound descriptor is non-local, feed it straight into the router → ITransportFactoryRegistry.ConnectToAsync (no string hop, no intermediate schema), opening an McpClientOverTransport; when local ({"type":"local"}), keep the in-process path (no round-trip). Add the NEW McpChannelClientTransport bridge (M2) and the production RemoteMcpHostHandler (M3), and register the handler on a production McpTransportListener in WorkspacesTransportComposition. This makes ExecutorTargetRouter a production consumer (G8) and provides the arbitrary stdio/HTTP MCP host (G9).

Reuse-first / no new schema. Routing feeds the bound connection-descriptor (the existing type-discriminated JSON: local, user-computer-profile, http, reverse-http, …) directly into the transport factory registry — there is NO ExecutorDescriptor and no new execution schema. Nesting is host-OUTER, target-INNER: {"type":"user-computer-profile","entity-id":"<host>","target":{ ...inner... }} — the OUTER descriptor reaches the host, the INNER target is what runs there (the TargetedTransport seam already established by UserComputerProfileTransportFactory.cs:47-106).

Files

Phantom.Workspaces.Llm.Core/McpToolContextProvider.cs, Phantom.Workspaces.Llm.Core/AgentChat.cs, Phantom.Workspaces.Transport.Mcp/McpChannelClientTransport.cs (new — M2), Phantom.Workspaces.Transport.Mcp/RemoteMcpHostHandler.cs (new — M3), Phantom.Workspaces/Services/WorkspacesTransportComposition.cs.

Detailed design

Cohesion seam M2 — McpToolContextProvider consumes a descriptor; add the McpChannelClientTransport bridge

Today: the ctor is McpToolContextProvider(McpTool tool, ILoggerFactory? loggerFactory, ExecutorTarget executorTarget, AgentServices? services) (Phantom.Workspaces.Llm.Core/McpToolContextProvider.cs:26-37). ProvideAIContextAsync (:47-102) connects in-process via McpTransportFactory.CreateMcpTransportAsync(...) (:65) → McpClient.CreateAsync(...) (:71) and ignores the ExecutorTarget property (:43, never consumed). Core gap.

Change:

NEW class McpChannelClientTransport (required deliverable of this issue). The MCP SDK client (McpClient.CreateAsync) needs an IClientTransport; a routed transport only gives us an IMessageChannel. McpChannelClientTransport is the adapter that bridges the transport IMessageChannel to IClientTransport so McpClient.CreateAsync can run over the routed channel. Without it the remote branch has no way to reach the SDK client.

  • Namespace: Phantom.Workspaces.Transport.Mcp.
  • Kind: class implementing ModelContextProtocol.Client.IClientTransport over an IMessageChannel.
  • Wrapped by McpClientOverTransport; local path does not use it.

RemoteMcpHostHandler (NEW/PROPOSED — M3)

Namespace: Phantom.Workspaces.Transport.Mcp (or Phantom.Workspaces.Services)
Kind: class (the Func<JsonElement, IMessageChannel, CancellationToken, Task<IAsyncDisposable?>> registered on McpTransportListener)
Responsibility: on the remote host, open an arbitrary stdio/HTTP MCP connection described by the inbound {"type":"mcp","connection":{...}} request and bridge it to the caller's message channel. This is the production openConnectionAsync that G9 says is missing.

Handler steps (a–e):

  • a. parse the connection request (tool-type-name + tool-entity-id);
  • b. resolve the MCP tool config on the remote host using the executor-scoped resolver from [per-component-executor] - MCP mcp-server-entity resolution scoped to the bound executor #1439McpServerEntityToolResourceFactory(IDataAccessLayer dataAccessLayer, IReadOnlyList<EntityName> searchPrefixes) (Phantom.Workspaces/McpServerEntityToolResourceFactory.cs:21-113) with the remote machine's prefix FIRST (machine profile → ${USER}/mcp-serversdefaults/mcp-servers);
  • c. launch the in-process MCP transport there (shared McpTransportFactory);
  • d. bridge it to the incoming IMessageChannel;
  • e. dispose on channel close (return the tear-down IAsyncDisposable).

Member: Task<IAsyncDisposable?> OpenAsync(JsonElement request, IMessageChannel channel, CancellationToken ct).

Cohesion seam M3 — production McpTransportListener registration in WorkspacesTransportComposition

WorkspacesTransportComposition (ctor Phantom.Workspaces/Services/WorkspacesTransportComposition.cs:37-82) today registers ONLY ChatClientTransportListener on LocalListeners (:54-63). McpTransportListener (Func<JsonElement,IMessageChannel,CT,Task<IAsyncDisposable?>>) exists (Phantom.Workspaces.Transport/Mcp/McpTransportListener.cs:9-27) but new McpTransportListener(...) appears only in tests today. Confirms G9.

This issue adds a PRODUCTION McpTransportListener registration on LocalListeners, wired to RemoteMcpHostHandler.OpenAsync, next to the existing ChatClientTransportListener registration.

#1438#1439 touchpoint (make explicit)

The remote host handler (step b) CONSUMES #1439's scoped resolverMcpServerEntityToolResourceFactory with the bound executor's machine prefix FIRST. So #1438 depends on #1439's resolver semantics even though #1439 is currently listed as depending on #1438 for the transport plumbing. This is a two-way touchpoint, not a dependency-direction change: the transport/host shell (McpTransportListener registration + RemoteMcpHostHandler + McpChannelClientTransport) lands in #1438; the scoped search-prefix wiring (remote machine prefix first) lands in #1439; they meet at the handler. The dependency edges stay as-is (#1439 depends-on #1438); both issues document this seam.

ExecutorTargetRouter (EXISTING — becomes production consumer)

No shape change; this feature adds its first production new ExecutorTargetRouter(...) in the session build path and threads it into McpToolContextProvider (closing G8).

Modified files (applicable):

  • Phantom.Workspaces.Llm.Core/McpToolContextProvider.cs — accept a resolved connection-descriptor + router; branch local (in-process) vs. remote (router → McpChannelClientTransportMcpClientOverTransport).
  • Phantom.Workspaces.Llm.Core/AgentChat.cs — construct each McpToolContextProvider with its bound connection-descriptor (RuntimeContextProviderRegistration.ConnectionDescriptor, AgentChat.cs:2965-2969, constructed :2389-2415) and the production ExecutorTargetRouter.
  • Phantom.Workspaces/Services/WorkspacesTransportComposition.cs — register the production RemoteMcpHostHandler on LocalListeners via McpTransportListener (:54-63 currently only ChatClientTransportListener).

Verified starting state (evidence):

  • McpToolContextProvider connects in-process via McpTransportFactory.CreateMcpTransportAsync (McpToolContextProvider.cs:65) → McpClient.CreateAsync (:71). Its ExecutorTarget property (:43) is never consumed. Core gap.
  • ExecutorTargetRouter maps target → client-instance via ExecutorTopology, builds a descriptor via ExecutionTargetResolver, and connects via ITransportFactoryRegistry (Phantom.Workspaces.Llm.Core/Transport/ExecutorTargetRouter.cs:17-49). It has no production consumer.
  • UserComputerProfileTransportFactory establishes the host-outer/target-inner recursive-resolve pattern reused here (:47-106). Existing descriptor types: local, user-computer-profile, http, reverse-http.
  • McpTransportListener accepts {"type":"mcp","connection":{...}} and delegates to a registered openConnectionAsync callback, wrapping the result in McpServerSession (McpTransportListener.cs:9-27). No production openConnectionAsync is registeredWorkspacesTransportComposition registers only a ChatClientTransportListener (WorkspacesTransportComposition.cs:54-63). Confirms G9.
  • AgentChat tags tools via Core.Transport.ExecutorTargetResolver.ForTool(tool) and constructs each McpToolContextProvider with that target (AgentChat.cs:2403-2415) — but nothing routes them remotely per tag.

Data flow (applicable parts):

  • Build step 5: AgentChat constructs each McpToolContextProvider with its bound connection-descriptor and the production ExecutorTargetRouter (built from ExecutorBindings.ToTopology() and the ITransportFactoryRegistry). DeferredTrustedExecutorSelector.SetTopology is set from the same topology so CustomTool (gui-local) routing is unchanged.
  • Runtime step 7: McpToolContextProvider.ProvideAIContextAsync runs lazily. If its bound descriptor is {"type":"local"}, it connects in-process exactly as today. Otherwise it feeds the connection-descriptor into the router → ITransportFactoryRegistry.ConnectToAsync, then opens McpClientOverTransport over the channel via McpChannelClientTransport (M2).
  • Runtime step 8: On the remote host, the inbound {"type":"mcp","connection":{...}} channel is served by the production RemoteMcpHostHandler registered on McpTransportListener (M3), which resolves the tool config via the [per-component-executor] - MCP mcp-server-entity resolution scoped to the bound executor #1439 scoped resolver (machine prefix first), opens the described stdio/HTTP MCP server locally, and bridges it back.
  • Runtime step 9: Tool listing / calls flow over the channel; results and tool-errors round-trip.

Tests

McpToolContextProviderRoutingTests (Phantom.Workspaces.Llm.Core.Tests)

  • ProvideAIContext_BoundLocal_UsesInProcessFactory_NoRoundTrip
  • ProvideAIContext_BoundRemote_ConnectsViaRouter
  • ProvideAIContext_BoundRemote_ExecutorTargetRouterExercisedAsProductionConsumer
  • ProvideAIContext_BoundRemote_BridgesChannelViaMcpChannelClientTransport(M2) the remote branch reaches the MCP SDK client through McpChannelClientTransport (IClientTransport over IMessageChannel).

McpChannelClientTransportTests (Phantom.Workspaces.Transport.Tests/Mcp)

  • Send_Receive_PumpsMessagesOverChannel(M2) the adapter round-trips MCP JSON-RPC frames over an in-process IMessageChannel.
  • Dispose_ClosesUnderlyingChannel

RemoteMcpHostHandlerTests (Phantom.Workspaces.Transport.Tests/Mcp)

WorkspacesTransportCompositionTests (Phantom.Workspaces.Tests)

  • Composition_RegistersProductionMcpTransportListener(M3) the production composition registers an McpTransportListener (wired to RemoteMcpHostHandler) alongside ChatClientTransportListener.

Scenario3_PerMcpServerRoutingTests (Phantom.Workspaces.Transport.Tests/Scenarios)

Mirrors Scenario2_GuiLocalToolRoutingTests.

  • Scenario3_LocalBoundMcpServer_ConnectsInProcess_NoTransportRoundTrip
  • Scenario3_RemoteBoundMcpServer_RoutesOverTransport
  • Scenario3_RemoteHost_OpensStdioMcpConnection_ViaProductionHandler
  • Scenario3_RemoteHost_OpensHttpMcpConnection_ViaProductionHandler
  • Scenario3_RemoteBoundMcpServer_ToolCall_RoundTripsResult
  • Scenario3_RemoteBoundMcpServer_ToolError_RoundTripsError

Testing strategy — Transport scenario tests (integration, hermetic): Mirror Scenario2_GuiLocalToolRoutingTests (in-process TransportRegistry machines reached over LocalTransport; routing via ExecutorTargetRouter). A tool bound local connects in-process — assert no transport round-trip. A tool bound to a remote executor routes over transport (bound descriptor fed straight into the factory registry; channel bridged via McpChannelClientTransport). The remote host opens both a stdio and an HTTP MCP connection via the production openConnectionAsync handler, resolving the tool config with the machine prefix first. An end-to-end tool call round-trips a result, and a failing tool call round-trips a tool-error.

Testing strategy — McpToolContextProvider behaviour: Bound-local uses the in-process factory; bound-remote uses the router + McpChannelClientTransport; ExecutorTargetRouter is exercised as a production consumer (closing G8). → McpToolContextProviderRoutingTests.

Testing strategy — Remote MCP host handler: OpenAsync hosts a stdio server and an HTTP server; resolves the tool config with the bound machine prefix first (#1439 touchpoint); an unrecognised connection descriptor returns null (so McpTransportListener declines it); disposes on channel close. → RemoteMcpHostHandlerTests.

Considered / Background

Re-using ChatClientTransportListener (which remotes a whole AgentChat) to host per-tool MCP was rejected — it cannot express "one arbitrary stdio/HTTP MCP server hosted remotely"; the dedicated McpTransportListener + RemoteMcpHostHandler seam is required (G9). Passing the SDK client an in-process transport for the remote branch was likewise impossible without the McpChannelClientTransport adapter, since the router yields only an IMessageChannel (M2).

Dependencies

Depends on: #1435PhantomMcpTool.Executor field
Depends on: #1436 — Executor-resource resolver (supplies the bound connection-descriptor + populates RuntimeContextProviderRegistration.ConnectionDescriptor — M1)
Depends on: #1437 — Explicit session executor + executor-bindings persistence + resume

Touchpoint with #1439 — the remote host handler (step b) CONSUMES #1439's McpServerEntityToolResourceFactory scoped resolver (machine prefix first). Documented in both issues; #1439 remains depends-on #1438 for the transport/host shell.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions