Skip to main content

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(&registry));
261        let mut views = HashMap::new();
262        views.insert(
263            String::new(),
264            CallerView {
265                registry: Arc::clone(&registry),
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(&registry));
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;