forge_sdk/mcp/orchestration.rs
1//! Glue between `McpServer` instances and the `claude` binary.
2//!
3//! Semantics:
4//! - Keeps a `HashMap<server_name, McpServer>` for dispatch.
5//! - Produces the `--mcp-config` inline JSON argv for subprocess spawn.
6//! - Routes `mcp_message` `control_request`s to the right server.
7//!
8//! No transport ownership: servers are in-process, dispatch is a direct
9//! method call.
10
11use std::collections::HashMap;
12
13use crate::mcp::McpServer;
14use crate::mcp::protocol::{JsonRpcRequest, JsonRpcResponse};
15use forge_primitives::McpServerConfig;
16
17/// Outcome of routing an `mcp_message`. Kept three-way so an unroutable
18/// message cannot share a representation with a notification: the caller
19/// answers a notification with an id-less empty result, which for an unknown
20/// server would report success for something that never ran.
21pub(crate) enum Dispatched {
22 /// The server produced a response to send back.
23 Response(Box<JsonRpcResponse>),
24 /// A JSON-RPC notification - no response is owed.
25 Notification,
26 /// No server is registered under that name.
27 UnknownServer,
28}
29
30/// Registry of MCP servers (both in-process SDK and external stdio /
31/// SSE / HTTP). Keyed by the name under which the caller registered
32/// them via `OptionsBuilder::mcp_server` / `external_mcp_server`.
33#[derive(Clone, Debug, Default)]
34pub(crate) struct McpHosts {
35 servers: HashMap<String, McpServer>,
36 external: HashMap<String, McpServerConfig>,
37}
38
39impl McpHosts {
40 /// Build from the `Options.mcp_servers` vector of in-process servers
41 /// plus a `HashMap` of external configs. Either side may be empty.
42 pub(crate) fn new(
43 sdk_entries: Vec<(String, McpServer)>,
44 external: HashMap<String, McpServerConfig>,
45 ) -> Self {
46 let mut servers = HashMap::new();
47 for (name, server) in sdk_entries {
48 servers.insert(name, server);
49 }
50 Self { servers, external }
51 }
52
53 /// True when no servers of either flavour are registered.
54 pub(crate) fn is_empty(&self) -> bool {
55 self.servers.is_empty() && self.external.is_empty()
56 }
57
58 /// Build the `--mcp-config` inline JSON argument. SDK servers emit
59 /// `{"type":"sdk","name":<n>}`; external servers serialise via
60 /// [`McpServerConfig`]'s own serde impl. Returns `"{"mcpServers":{}}"`
61 /// when empty - caller should skip adding the flag in that case.
62 pub(crate) fn config_argv(&self) -> String {
63 let mut servers: serde_json::Map<String, serde_json::Value> = self
64 .servers
65 .keys()
66 .map(|name| (name.clone(), serde_json::json!({"type": "sdk", "name": name})))
67 .collect();
68 for (name, cfg) in &self.external {
69 match serde_json::to_value(cfg) {
70 Ok(v) => {
71 servers.insert(name.clone(), v);
72 }
73 Err(e) => {
74 tracing::warn!(
75 %name,
76 error = %e,
77 "external MCP server config failed to serialise; omitting from --mcp-config"
78 );
79 }
80 }
81 }
82 serde_json::json!({"mcpServers": servers}).to_string()
83 }
84
85 /// Route an incoming `mcp_message` to the named server.
86 pub(crate) async fn dispatch(&self, server_name: &str, req: &JsonRpcRequest) -> Dispatched {
87 let Some(server) = self.servers.get(server_name) else {
88 return Dispatched::UnknownServer;
89 };
90 match server.dispatch(req).await {
91 Some(r) => Dispatched::Response(Box::new(r)),
92 None => Dispatched::Notification,
93 }
94 }
95}