llm_tool_mcp/server/mod.rs
1//! MCP stdio server backed by a [`ToolRegistry`].
2//!
3//! [`McpServer`] wraps a [`ToolRegistry`] and exposes it via the
4//! [Model Context Protocol](https://modelcontextprotocol.io/) over any
5//! `BufRead`/`Write` pair (typically stdin/stdout).
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌─────────┐ JSON-RPC ┌───────────┐ dispatch ┌──────────────┐
11//! │ Client │──────────────▶│ McpServer │─────────────▶│ ToolRegistry │
12//! │ (stdin) │◀──────────────│ │◀─────────────│ │
13//! └─────────┘ JSON-RPC └───────────┘ Result └──────────────┘
14//! ```
15//!
16//! # Performance
17//!
18//! - MCP tool schemas are computed **once** at construction and cached.
19//! - [`run`](McpServer::run) creates a **single** tokio `current_thread`
20//! runtime, reused for all dispatches.
21//! - The `"2.0"` JSON-RPC version is a `&'static str` to avoid allocation.
22
23use std::{
24 collections::HashMap,
25 sync::{Arc, Mutex},
26};
27
28use llm_tool::{PromptRegistry, ResourceRegistry, ToolContext, ToolDefinition, ToolRegistry};
29use tracing::warn;
30
31// Re-exported so the `#[cfg(test)] mod tests` submodule keeps the same protocol
32// names in scope (via its glob import of this module) as before the split into
33// `builder` / `transport` / `dispatch`.
34#[cfg(test)]
35pub(crate) use crate::protocol::{self, *};
36use crate::protocol::{McpToolSchema, ToolsListResult};
37
38mod builder;
39mod dispatch;
40mod transport;
41
42pub use builder::McpServerBuilder;
43pub use dispatch::RpcOutcome;
44pub use transport::Transport;
45
46/// An MCP server that serves tools from a [`ToolRegistry`] over JSON-RPC.
47///
48/// # Example
49///
50/// ```rust
51/// use llm_tool::{ToolContext, ToolError, ToolRegistry, llm_tool};
52/// use llm_tool_mcp::McpServer;
53///
54/// /// Adds two numbers.
55/// #[llm_tool]
56/// fn add(
57/// /// First operand.
58/// a: i64,
59/// /// Second operand.
60/// b: i64,
61/// ) -> Result<String, ToolError> {
62/// Ok(format!("{}", a + b))
63/// }
64///
65/// let registry = ToolRegistry::new().with_tool(Add);
66/// let ctx = ToolContext::new().with_conversation_id("my-agent");
67///
68/// let server = McpServer::new("my-server", "0.1.0", registry)
69/// .with_context(ctx);
70///
71/// // In production: server.run_stdio().expect("MCP server failed");
72/// // Here we prove it works with an in-memory request:
73/// let input = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":2}}}"#;
74/// let reader = std::io::Cursor::new(format!("{input}\n"));
75/// let mut output = Vec::new();
76/// server.run(reader, &mut output).unwrap();
77///
78/// let resp: serde_json::Value = serde_json::from_slice(&output).unwrap();
79/// assert_eq!(resp["result"]["content"][0]["text"], "3");
80/// ```
81#[derive(Clone)]
82pub struct McpServer {
83 name: String,
84 version: String,
85 instructions: Option<String>,
86 registry: Arc<ToolRegistry>,
87 context: Arc<ToolContext>,
88 /// Pre-serialized `tools/list` result body — built once at construction and
89 /// wrapped in `Arc` so `tools/list` clones a pointer plus one JSON value,
90 /// never re-serializing the schema tree.
91 cached_tools_list: Arc<serde_json::Value>,
92 prompts: Arc<PromptRegistry>,
93 resources: Arc<ResourceRegistry>,
94 /// When `true`, each connection derives its own caller identity from the
95 /// `clientInfo.name` sent in that connection's MCP `initialize` handshake,
96 /// letting a single server serve many distinct callers. When `false` (the
97 /// default) every dispatch uses the one shared [`ToolContext`] identity.
98 per_connection_identity: bool,
99 /// Optional factory that builds a **per-caller** [`ToolRegistry`] the first
100 /// time each negotiated caller is seen. When set, `tools/list` and
101 /// `tools/call` use the caller's own registry (see [`RegistryFactory`]);
102 /// when `None`, every connection shares [`registry`](Self::registry).
103 registry_factory: Option<Arc<dyn RegistryFactory>>,
104 /// Memoized caller → view cache. Keyed by the negotiated caller name (empty
105 /// string for the default/no-identity view). Shared across cloned servers
106 /// and connections so each caller's registry is built and serialized once.
107 caller_views: Arc<Mutex<HashMap<String, CallerView>>>,
108}
109
110/// Builds the [`ToolRegistry`] a given caller should see.
111///
112/// A single server can present a *different* tool set — and different tool
113/// descriptions — to each connection based on the caller negotiated at
114/// `initialize`. This is what lets "one MCP, many agents" preserve per-caller
115/// tailoring (gating privileged tools, personalising descriptions, …) rather
116/// than serving one fixed tool list to everyone.
117///
118/// Any `Fn(Option<&str>) -> ToolRegistry` implements this trait, so a closure
119/// is usually all that's needed. Pair it with
120/// [`with_per_connection_identity`](McpServerBuilder::with_per_connection_identity)
121/// so a caller is actually negotiated:
122///
123/// ```rust
124/// use llm_tool::ToolRegistry;
125/// use llm_tool_mcp::McpServer;
126///
127/// let server = McpServer::builder("srv", "0.1.0", ToolRegistry::new())
128/// .with_per_connection_identity(true)
129/// .with_registry_factory(|_caller: Option<&str>| {
130/// // Build a registry tailored to `_caller`.
131/// ToolRegistry::new()
132/// })
133/// .build();
134/// # let _server = server;
135/// ```
136pub trait RegistryFactory: Send + Sync {
137 /// Build the registry for `caller`.
138 ///
139 /// `caller` is `None` for the default view (before/without a negotiated
140 /// identity), or `Some(name)` for a connection that announced itself.
141 fn registry_for(&self, caller: Option<&str>) -> ToolRegistry;
142}
143
144impl<F> RegistryFactory for F
145where
146 F: Fn(Option<&str>) -> ToolRegistry + Send + Sync,
147{
148 fn registry_for(&self, caller: Option<&str>) -> ToolRegistry {
149 self(caller)
150 }
151}
152
153/// A caller-specific view: the registry to dispatch against plus its
154/// pre-serialized `tools/list` body. Cheap to clone (two `Arc`s).
155#[derive(Clone)]
156struct CallerView {
157 registry: Arc<ToolRegistry>,
158 tools_list: Arc<serde_json::Value>,
159}
160
161/// Per-connection negotiated state, owned by each transport run loop.
162///
163/// Custom transports built on [`handle_message_conn`](McpServer::handle_message_conn)
164/// create one [`Connection`] per client (via [`Connection::new`] /
165/// [`Default`]) and pass `&mut` to it for every message on that connection, so
166/// the caller identity and per-caller registry view negotiated at `initialize`
167/// persist for the connection's lifetime.
168///
169/// Defaults to "nothing negotiated yet": dispatch then falls back to the
170/// server's shared identity, registry, and cached `tools/list`.
171#[derive(Default)]
172pub struct Connection {
173 /// Caller identity adopted from this connection's `initialize` handshake
174 /// (when per-connection identity is enabled); `None` uses the shared one.
175 ctx: Option<ToolContext>,
176 /// Caller-specific registry view (when a [`RegistryFactory`] is set);
177 /// `None` uses the server's shared registry + cached `tools/list`.
178 view: Option<CallerView>,
179}
180
181impl Connection {
182 /// Create a fresh, un-negotiated connection state.
183 ///
184 /// Equivalent to [`Connection::default`]; provided for call-site clarity in
185 /// transport loops.
186 #[must_use]
187 pub fn new() -> Self {
188 Self::default()
189 }
190}
191
192impl McpServer {
193 /// Create a new MCP server serving tools from `registry`.
194 ///
195 /// The `name` and `version` are reported in the MCP `initialize` response.
196 /// To also register prompts or resources, use [`builder`](Self::builder).
197 ///
198 /// Tool schemas are computed **once** here and cached for all subsequent
199 /// `tools/list` requests.
200 #[must_use]
201 pub fn new(
202 name: impl Into<String>,
203 version: impl Into<String>,
204 registry: ToolRegistry,
205 ) -> Self {
206 McpServerBuilder::new(name, version, registry).build()
207 }
208
209 /// Begin building a server, allowing prompts and resources to be registered
210 /// before [`build`](McpServerBuilder::build).
211 #[must_use]
212 pub fn builder(
213 name: impl Into<String>,
214 version: impl Into<String>,
215 registry: ToolRegistry,
216 ) -> McpServerBuilder {
217 McpServerBuilder::new(name, version, registry)
218 }
219
220 /// Set the [`ToolContext`] used for all tool dispatches.
221 ///
222 /// The context provides the conversation ID and a shared state store
223 /// that persists across tool calls.
224 #[must_use]
225 pub fn with_context(mut self, context: ToolContext) -> Self {
226 self.context = Arc::new(context);
227 self
228 }
229
230 /// Provide instructions describing how to use the server.
231 #[must_use]
232 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
233 self.instructions = Some(instructions.into());
234 self
235 }
236
237 /// Enable per-connection caller identity.
238 ///
239 /// When enabled, each connection derives its own caller from the
240 /// `clientInfo.name` in its MCP `initialize` handshake (sharing the base
241 /// context's state and typed extensions via [`ToolContext::with_caller`]),
242 /// so a single long-lived server can serve many agents under distinct
243 /// personas. Defaults to `false`, preserving the single-caller behaviour.
244 #[must_use]
245 pub const fn with_per_connection_identity(mut self, enabled: bool) -> Self {
246 self.per_connection_identity = enabled;
247 self
248 }
249
250 /// Serve a **per-caller** tool registry via a [`RegistryFactory`].
251 ///
252 /// Post-construction counterpart of
253 /// [`McpServerBuilder::with_registry_factory`]: it recomputes the default
254 /// view from `registry_for(None)` and reseeds the caller cache, so a server
255 /// created with [`new`](Self::new) can still opt into per-caller registries.
256 #[must_use]
257 pub fn with_registry_factory<F: RegistryFactory + 'static>(mut self, factory: F) -> Self {
258 let factory: Arc<dyn RegistryFactory> = Arc::new(factory);
259 let registry = Arc::new(factory.registry_for(None));
260 let cached_tools_list = Arc::new(build_tools_list_value(®istry));
261 let mut views = HashMap::new();
262 views.insert(
263 String::new(),
264 CallerView {
265 registry: Arc::clone(®istry),
266 tools_list: Arc::clone(&cached_tools_list),
267 },
268 );
269 self.registry = registry;
270 self.cached_tools_list = cached_tools_list;
271 self.registry_factory = Some(factory);
272 self.caller_views = Arc::new(Mutex::new(views));
273 self
274 }
275
276 /// Lock the caller-view cache, recovering a poisoned guard rather than
277 /// propagating the panic.
278 ///
279 /// The cache holds only derived, idempotently-rebuildable views, so reusing
280 /// a poisoned guard is safe. The poisoning is logged so the original panic
281 /// isn't silently swallowed.
282 fn lock_views(&self) -> std::sync::MutexGuard<'_, HashMap<String, CallerView>> {
283 self.caller_views.lock().unwrap_or_else(|poisoned| {
284 warn!("caller-view cache mutex was poisoned; recovering guard");
285 poisoned.into_inner()
286 })
287 }
288
289 /// Resolve (and memoize) the caller-specific [`CallerView`] for `caller`.
290 ///
291 /// Returns `None` when no [`RegistryFactory`] is configured, so dispatch
292 /// falls back to the shared registry and cached `tools/list`. The registry
293 /// is built and its schema serialized only once per distinct caller.
294 fn resolve_view(&self, caller: Option<&str>) -> Option<CallerView> {
295 let factory = self.registry_factory.as_ref()?;
296 // `None` (no negotiated caller) maps to the empty-string default-view key.
297 let key = caller.unwrap_or("");
298
299 // Fast path: hand back a cached view without holding the lock across the
300 // (potentially expensive) build below.
301 if let Some(view) = self.lock_views().get(key) {
302 return Some(view.clone());
303 }
304
305 // Build outside the lock: registry construction and schema serialization
306 // can be costly, and many agents may initialize concurrently.
307 let registry = Arc::new(factory.registry_for(caller));
308 let tools_list = Arc::new(build_tools_list_value(®istry));
309 let view = CallerView {
310 registry,
311 tools_list,
312 };
313
314 // Insert, tolerating a concurrent build that beat us to this caller.
315 Some(
316 self.lock_views()
317 .entry(key.to_owned())
318 .or_insert(view)
319 .clone(),
320 )
321 }
322
323 /// Borrow the underlying [`ToolRegistry`].
324 ///
325 /// Useful for extracting definitions or dispatching outside MCP.
326 #[must_use]
327 pub fn registry(&self) -> &ToolRegistry {
328 &self.registry
329 }
330}
331
332// ── Schema helpers ──────────────────────────────────────────────────
333
334/// Build and pre-serialize the cached `tools/list` response body.
335///
336/// Called once at [`McpServerBuilder::build`] — the resulting JSON value is
337/// wrapped in an `Arc` so `tools/list` clones a pointer plus one value rather
338/// than re-serializing the schema tree on every request.
339fn build_tools_list_value(registry: &ToolRegistry) -> serde_json::Value {
340 let tools = registry
341 .definitions()
342 .iter()
343 .map(definition_to_mcp_schema)
344 .collect();
345 let list = ToolsListResult { tools };
346 serde_json::to_value(list).expect("tools/list schema must be JSON-serializable")
347}
348
349/// Convert a [`ToolDefinition`] to the MCP `tools/list` schema format.
350fn definition_to_mcp_schema(def: &ToolDefinition) -> McpToolSchema {
351 McpToolSchema {
352 name: def.name.clone(),
353 description: def.description.clone(),
354 input_schema: def.parameter_schema.clone(),
355 }
356}
357
358#[cfg(test)]
359mod tests;