llm_tool_mcp/server/dispatch.rs
1//! JSON-RPC request dispatch and the [`RpcOutcome`] wire type.
2
3use std::fmt;
4
5use llm_tool::{ToolContext, ToolRegistry};
6use tracing::{debug, info};
7
8use super::{Connection, McpServer};
9use crate::protocol::{
10 self, Capabilities, ContentItem, InitializeResult, JSONRPC_VERSION, JsonRpcRequest,
11 JsonRpcResponse, PromptCapabilities, ResourceCapabilities, ServerInfo, ToolCallParams,
12 ToolCallResult, ToolCapabilities,
13};
14
15impl McpServer {
16 /// Handle exactly one JSON-RPC request string, always producing a response.
17 ///
18 /// A **test-only** convenience over
19 /// [`handle_request_conn`](Self::handle_request_conn) that uses the shared
20 /// server identity (no per-connection caller). Production transports drive
21 /// `handle_request_conn` so each connection keeps its own negotiated
22 /// identity. Public callers should use [`handle_message`](Self::handle_message),
23 /// which additionally understands batches and notification-only input.
24 ///
25 /// Safe to call from within an existing tokio runtime.
26 #[cfg(test)]
27 pub(crate) async fn handle_request(&self, line: &str) -> JsonRpcResponse {
28 self.handle_request_conn(line, &mut Connection::default())
29 .await
30 }
31
32 /// Connection-aware variant of [`handle_request`](Self::handle_request).
33 ///
34 /// `conn` carries the caller identity and per-caller registry view
35 /// negotiated for this connection's `initialize` handshake; both are threaded
36 /// into dispatch so per-connection identity and per-caller tool sets (when
37 /// enabled) apply to `tools/list` and `tools/call`.
38 async fn handle_request_conn(&self, line: &str, conn: &mut Connection) -> JsonRpcResponse {
39 // Detect batch requests (JSON arrays) — redirect to handle_message.
40 if let Some(first_non_ws) = line.trim_start().as_bytes().first() {
41 if *first_non_ws == b'[' {
42 return JsonRpcResponse::error(
43 None,
44 protocol::INVALID_REQUEST,
45 "batch requests must be processed via handle_message or run/run_async",
46 );
47 }
48 }
49
50 let val: serde_json::Value = match serde_json::from_str(line) {
51 Ok(r) => r,
52 Err(e) => {
53 return JsonRpcResponse::error(
54 None,
55 protocol::PARSE_ERROR,
56 format!("invalid JSON: {e}"),
57 );
58 }
59 };
60
61 let Some(obj) = val.as_object() else {
62 return JsonRpcResponse::error(
63 None,
64 protocol::INVALID_REQUEST,
65 "expected JSON-RPC request object",
66 );
67 };
68
69 let id = obj.get("id").cloned();
70 let request: JsonRpcRequest = match serde_json::from_value(val) {
71 Ok(r) => r,
72 Err(e) => {
73 return JsonRpcResponse::error(
74 id,
75 protocol::INVALID_REQUEST,
76 format!("invalid JSON-RPC request: {e}"),
77 );
78 }
79 };
80
81 // JSON-RPC 2.0 §4: the "jsonrpc" field MUST be exactly "2.0".
82 if request.version != JSONRPC_VERSION {
83 return JsonRpcResponse::error(
84 request.id,
85 protocol::INVALID_REQUEST,
86 format!(
87 "invalid jsonrpc version: expected \"2.0\", got \"{}\"",
88 request.version
89 ),
90 );
91 }
92
93 self.dispatch_method(request, conn).await
94 }
95
96 /// Handle one JSON-RPC *message* and return the response to send back.
97 ///
98 /// This is **the** entry point for building a custom transport (Axum HTTP,
99 /// `WebSockets`, a message queue, etc.). The [`run`](Self::run) family is
100 /// built on top of it. A JSON-RPC message is either:
101 ///
102 /// - a single request/notification object (`{ ... }`), or
103 /// - a batch array of them (`[ { ... }, ... ]`).
104 ///
105 /// Each request's `method` is dispatched against this server's
106 /// [`ToolRegistry`] (`tools/list`, `tools/call`) and any registered prompts
107 /// (`prompts/*`) and resources (`resources/*`); see the crate docs for the
108 /// full method table.
109 ///
110 /// The result is a structured [`RpcOutcome`] you inspect or render to the
111 /// wire in a single pass via [`to_wire`](RpcOutcome::to_wire) /
112 /// [`Display`](core::fmt::Display) / [`write_json`](RpcOutcome::write_json):
113 ///
114 /// - <code>Some([Single](RpcOutcome::Single))</code> — one request → one response.
115 /// - <code>Some([Batch](RpcOutcome::Batch))</code> — a batch → one response each,
116 /// for the non-notification members.
117 /// - `None` — the input was purely notification(s); send nothing back
118 /// (e.g. reply `202 Accepted` with no body over HTTP).
119 pub async fn handle_message(&self, line: &str) -> Option<RpcOutcome> {
120 self.handle_message_conn(line, &mut Connection::default())
121 .await
122 }
123
124 /// Connection-aware variant of [`handle_message`](Self::handle_message).
125 ///
126 /// `conn` is this connection's negotiated state: its caller identity slot
127 /// and per-caller registry view are updated by the `initialize` handshake
128 /// (when per-connection identity / a registry factory are enabled) and read
129 /// on subsequent `tools/list` and `tools/call`s. The transport run loops own
130 /// one such [`Connection`] per connection.
131 pub async fn handle_message_conn(
132 &self,
133 line: &str,
134 conn: &mut Connection,
135 ) -> Option<RpcOutcome> {
136 let trimmed = line.trim();
137 if trimmed.is_empty() {
138 return None;
139 }
140
141 if trimmed.as_bytes().first() == Some(&b'[') {
142 return self.dispatch_batch(trimmed, conn).await;
143 }
144
145 let response = self.handle_request_conn(trimmed, conn).await;
146 // JSON-RPC 2.0 §4.1: notifications (requests without an ID that succeeded)
147 // must not produce a response. Protocol-level errors with null IDs must be sent.
148 if response.id.is_none() && response.error.is_none() {
149 None
150 } else {
151 Some(RpcOutcome::Single(response))
152 }
153 }
154
155 /// Handle a JSON-RPC 2.0 batch request array, collecting one response per
156 /// non-notification member.
157 async fn dispatch_batch(&self, line: &str, conn: &mut Connection) -> Option<RpcOutcome> {
158 let val: serde_json::Value = match serde_json::from_str(line) {
159 Ok(v) => v,
160 Err(e) => {
161 return Some(RpcOutcome::Single(JsonRpcResponse::error(
162 None,
163 protocol::PARSE_ERROR,
164 format!("invalid JSON: {e}"),
165 )));
166 }
167 };
168
169 let Some(arr) = val.as_array() else {
170 return Some(RpcOutcome::Single(JsonRpcResponse::error(
171 None,
172 protocol::INVALID_REQUEST,
173 "expected JSON array for batch request",
174 )));
175 };
176
177 if arr.is_empty() {
178 return Some(RpcOutcome::Single(JsonRpcResponse::error(
179 None,
180 protocol::INVALID_REQUEST,
181 "batch request array cannot be empty",
182 )));
183 }
184
185 let mut responses = Vec::with_capacity(arr.len());
186 for item in arr {
187 let resp_opt = match serde_json::from_value::<JsonRpcRequest>(item.clone()) {
188 Ok(request) => {
189 if request.version == JSONRPC_VERSION {
190 let resp = self.dispatch_method(request, conn).await;
191 if resp.id.is_none() { None } else { Some(resp) }
192 } else {
193 Some(JsonRpcResponse::error(
194 request.id,
195 protocol::INVALID_REQUEST,
196 format!(
197 "invalid jsonrpc version: expected \"2.0\", got \"{}\"",
198 request.version
199 ),
200 ))
201 }
202 }
203 Err(e) => {
204 let id = item.as_object().and_then(|o| o.get("id").cloned());
205 Some(JsonRpcResponse::error(
206 id,
207 protocol::INVALID_REQUEST,
208 format!("invalid request object in batch: {e}"),
209 ))
210 }
211 };
212
213 if let Some(resp) = resp_opt {
214 responses.push(resp);
215 }
216 }
217
218 if responses.is_empty() {
219 None
220 } else {
221 Some(RpcOutcome::Batch(responses))
222 }
223 }
224
225 /// Dispatch a validated JSON-RPC request to the appropriate method handler.
226 async fn dispatch_method(
227 &self,
228 request: JsonRpcRequest,
229 conn: &mut Connection,
230 ) -> JsonRpcResponse {
231 let id = request.id.clone();
232
233 match request.method.as_str() {
234 protocol::METHOD_INITIALIZE => {
235 self.handle_initialize(id, request.params.as_ref(), conn)
236 }
237 // No-op acknowledgements: liveness/log-level, plus the `initialized`
238 // notification some clients send (both namespaced and bare forms).
239 protocol::METHOD_PING
240 | protocol::METHOD_LOGGING_SET_LEVEL
241 | protocol::METHOD_NOTIFICATIONS_INITIALIZED
242 | protocol::METHOD_INITIALIZED => {
243 JsonRpcResponse::success(id, protocol::EmptyResult {})
244 }
245 // Cancellation notifications — acknowledge silently.
246 protocol::METHOD_NOTIFICATIONS_CANCELLED => {
247 debug!("received cancellation notification");
248 JsonRpcResponse::success(id, protocol::EmptyResult {})
249 }
250 protocol::METHOD_TOOLS_LIST => self.handle_tools_list(id, conn),
251 protocol::METHOD_TOOLS_CALL => {
252 let ctx = conn.ctx.as_ref().unwrap_or(&self.context);
253 let registry = conn
254 .view
255 .as_ref()
256 .map_or_else(|| self.registry.as_ref(), |v| v.registry.as_ref());
257 self.handle_tools_call(id, request.params, ctx, registry)
258 .await
259 }
260 protocol::METHOD_RESOURCES_LIST => {
261 let list = protocol::ResourcesListResult {
262 resources: self
263 .resources
264 .definitions()
265 .into_iter()
266 .map(|def| protocol::Resource {
267 uri: def.uri_template,
268 name: def.name,
269 description: def.description,
270 mime_type: def.mime_type,
271 })
272 .collect(),
273 };
274 JsonRpcResponse::success(id, list)
275 }
276 protocol::METHOD_RESOURCES_TEMPLATES_LIST => {
277 let list = protocol::ResourceTemplatesListResult {
278 resource_templates: self.resources.definitions(),
279 };
280 JsonRpcResponse::success(id, list)
281 }
282 protocol::METHOD_RESOURCES_READ => self.handle_resources_read(id, request.params).await,
283 protocol::METHOD_PROMPTS_LIST => {
284 let list = protocol::PromptsListResult {
285 prompts: self.prompts.definitions(),
286 };
287 JsonRpcResponse::success(id, list)
288 }
289 protocol::METHOD_PROMPTS_GET => self.handle_prompts_get(id, request.params).await,
290 protocol::METHOD_COMPLETION_COMPLETE => {
291 JsonRpcResponse::success(id, protocol::CompletionCompleteResult::default())
292 }
293 protocol::METHOD_NOTIFICATIONS_PROGRESS | protocol::METHOD_NOTIFICATIONS_MESSAGE => {
294 debug!("received progress/message notification");
295 JsonRpcResponse::success(id, protocol::EmptyResult {})
296 }
297 other => JsonRpcResponse::error(
298 id,
299 protocol::METHOD_NOT_FOUND,
300 format!("unknown method: {other}"),
301 ),
302 }
303 }
304
305 // ── Method handlers ─────────────────────────────────────────────
306
307 /// MCP protocol version supported by this server.
308 const PROTOCOL_VERSION: &str = "2024-11-05";
309
310 fn handle_initialize(
311 &self,
312 id: Option<serde_json::Value>,
313 params: Option<&serde_json::Value>,
314 conn: &mut Connection,
315 ) -> JsonRpcResponse {
316 info!(server = %self.name, version = %self.version, "MCP initialize");
317
318 // Protocol version negotiation: if the client sends a
319 // `protocolVersion` in params, we report our supported version.
320 // The server always responds with the version it actually supports.
321 let mut caller: Option<&str> = None;
322 if let Some(p) = params {
323 if let Some(client_ver) = p.get("protocolVersion").and_then(|v| v.as_str()) {
324 debug!(client_version = %client_ver, server_version = Self::PROTOCOL_VERSION, "protocol version negotiation");
325 }
326
327 // When per-connection identity is enabled, adopt the caller name the
328 // client announced in `clientInfo.name` for this connection. The
329 // derived context shares the server's state and typed extensions, so
330 // every connection sees the same session while acting as itself.
331 if self.per_connection_identity {
332 if let Some(name) = p
333 .get("clientInfo")
334 .and_then(|c| c.get("name"))
335 .and_then(|n| n.as_str())
336 .map(str::trim)
337 .filter(|n| !n.is_empty())
338 {
339 info!(caller = %name, "adopting per-connection caller identity");
340 conn.ctx = Some(self.context.with_caller(name));
341 caller = Some(name);
342 } else {
343 debug!(
344 "per-connection identity enabled but client sent no \
345 usable clientInfo.name; using shared server identity"
346 );
347 }
348 }
349 }
350
351 // Resolve this caller's registry view. A no-op (leaves the shared
352 // registry in play) when no [`RegistryFactory`] is configured.
353 conn.view = self.resolve_view(caller);
354
355 let tools_cap = Some(ToolCapabilities {});
356 let prompts_cap = if self.prompts.is_empty() {
357 None
358 } else {
359 Some(PromptCapabilities {})
360 };
361 let resources_cap = if self.resources.is_empty() {
362 None
363 } else {
364 Some(ResourceCapabilities {})
365 };
366
367 JsonRpcResponse::success(
368 id,
369 InitializeResult {
370 protocol_version: Self::PROTOCOL_VERSION,
371 server_info: ServerInfo {
372 name: self.name.clone(),
373 version: self.version.clone(),
374 },
375 instructions: self.instructions.clone(),
376 capabilities: Capabilities {
377 tools: tools_cap,
378 resources: resources_cap,
379 prompts: prompts_cap,
380 },
381 },
382 )
383 }
384
385 fn handle_tools_list(
386 &self,
387 id: Option<serde_json::Value>,
388 conn: &Connection,
389 ) -> JsonRpcResponse {
390 // Per-caller registry view when negotiated; otherwise the shared list.
391 let (count, tools_list) = conn.view.as_ref().map_or_else(
392 || (self.registry.len(), &self.cached_tools_list),
393 |v| (v.registry.len(), &v.tools_list),
394 );
395 info!(count, "tools/list");
396 // The tools/list body is pre-serialized (per caller, at first use);
397 // clone the cached JSON value instead of re-serializing the schema tree.
398 JsonRpcResponse {
399 jsonrpc: JSONRPC_VERSION,
400 id,
401 result: Some((**tools_list).clone()),
402 error: None,
403 }
404 }
405
406 /// Dispatch a `tools/call` programmatically and return the typed result.
407 ///
408 /// This runs the tool through the **exact same** code path as the JSON-RPC
409 /// wire handler (`tools/call`), so the not-found and error mapping is
410 /// identical: an unknown tool or a failing handler both yield a
411 /// [`ToolCallResult`] with [`is_error`](ToolCallResult::is_error) set to
412 /// `true` and the message surfaced in the content — never a panic or a
413 /// JSON-RPC-level error.
414 ///
415 /// Prefer this over hand-building JSON-RPC frames when calling a tool from
416 /// Rust (e.g. in tests): you get the typed [`ToolCallResult`] directly and
417 /// can read [`ToolCallResult::text`] instead of indexing into JSON.
418 ///
419 /// # Example
420 ///
421 /// ```rust
422 /// # use llm_tool::{ToolContext, ToolError, ToolRegistry, llm_tool};
423 /// # use llm_tool_mcp::McpServer;
424 /// /// Adds two numbers.
425 /// #[llm_tool]
426 /// fn add(
427 /// /// First operand.
428 /// a: i64,
429 /// /// Second operand.
430 /// b: i64,
431 /// ) -> Result<String, ToolError> {
432 /// Ok(format!("{}", a + b))
433 /// }
434 ///
435 /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
436 /// let server = McpServer::new("srv", "0.1.0", ToolRegistry::new().with_tool(Add));
437 /// let result = server.dispatch_tool("add", serde_json::json!({"a": 2, "b": 3})).await;
438 /// assert!(!result.is_error);
439 /// assert_eq!(result.text(), Some("5"));
440 /// # })
441 /// ```
442 pub async fn dispatch_tool(&self, name: &str, arguments: serde_json::Value) -> ToolCallResult {
443 self.call_tool(name, arguments, &self.context, &self.registry)
444 .await
445 }
446
447 /// Shared tool-call core: dispatch against the registry and map the outcome
448 /// to a [`ToolCallResult`].
449 ///
450 /// Both the wire handler ([`handle_tools_call`](Self::handle_tools_call))
451 /// and the programmatic entry point ([`dispatch_tool`](Self::dispatch_tool))
452 /// funnel through here so the success / error / not-found mapping lives in
453 /// exactly one place.
454 ///
455 /// Per the MCP spec, tool execution errors — and unknown tools — are
456 /// reported as a result with `isError=true`, not as JSON-RPC errors, so the
457 /// model can recover within the turn. JSON-RPC errors are reserved for
458 /// protocol-level failures (handled by the caller).
459 async fn call_tool(
460 &self,
461 name: &str,
462 arguments: serde_json::Value,
463 ctx: &ToolContext,
464 registry: &ToolRegistry,
465 ) -> ToolCallResult {
466 debug!(tool = %name, caller = ?ctx.conversation_id(), "tools/call");
467 match registry.dispatch(name, arguments, ctx).await {
468 Ok(output) => ToolCallResult {
469 content: vec![ContentItem::text(output.into_content())],
470 is_error: false,
471 },
472 Err(e) => ToolCallResult {
473 content: vec![ContentItem::text(e.to_string())],
474 is_error: true,
475 },
476 }
477 }
478
479 async fn handle_tools_call(
480 &self,
481 id: Option<serde_json::Value>,
482 params: Option<serde_json::Value>,
483 ctx: &ToolContext,
484 registry: &ToolRegistry,
485 ) -> JsonRpcResponse {
486 let Some(raw_params) = params else {
487 return JsonRpcResponse::error(
488 id,
489 protocol::INVALID_PARAMS,
490 "tools/call requires params with 'name' and 'arguments'",
491 );
492 };
493
494 let call_params: ToolCallParams = match serde_json::from_value(raw_params) {
495 Ok(p) => p,
496 Err(e) => {
497 return JsonRpcResponse::error(
498 id,
499 protocol::INVALID_PARAMS,
500 format!("invalid tools/call params: {e}"),
501 );
502 }
503 };
504
505 let result = self
506 .call_tool(&call_params.name, call_params.arguments, ctx, registry)
507 .await;
508 JsonRpcResponse::success(id, result)
509 }
510
511 async fn handle_prompts_get(
512 &self,
513 id: Option<serde_json::Value>,
514 params: Option<serde_json::Value>,
515 ) -> JsonRpcResponse {
516 let Some(p_val) = params else {
517 return JsonRpcResponse::error(
518 id,
519 protocol::INVALID_PARAMS,
520 "missing params for prompts/get",
521 );
522 };
523 let get_params: protocol::GetPromptParams = match serde_json::from_value(p_val) {
524 Ok(v) => v,
525 Err(e) => {
526 return JsonRpcResponse::error(
527 id,
528 protocol::INVALID_PARAMS,
529 format!("invalid params: {e}"),
530 );
531 }
532 };
533 match self
534 .prompts
535 .render(&get_params.name, get_params.arguments)
536 .await
537 {
538 Ok(output) => {
539 let messages = output
540 .messages
541 .into_iter()
542 .map(|m| protocol::PromptMessage {
543 role: m.role.to_string(),
544 content: protocol::PromptMessageContent::Text { text: m.content },
545 })
546 .collect();
547 let res = protocol::GetPromptResult {
548 description: None,
549 messages,
550 };
551 JsonRpcResponse::success(id, res)
552 }
553 Err(err) => JsonRpcResponse::error(id, protocol::INVALID_PARAMS, err.message),
554 }
555 }
556
557 async fn handle_resources_read(
558 &self,
559 id: Option<serde_json::Value>,
560 params: Option<serde_json::Value>,
561 ) -> JsonRpcResponse {
562 let Some(p_val) = params else {
563 return JsonRpcResponse::error(
564 id,
565 protocol::INVALID_PARAMS,
566 "missing params for resources/read",
567 );
568 };
569 let read_params: protocol::ReadResourceParams = match serde_json::from_value(p_val) {
570 Ok(v) => v,
571 Err(e) => {
572 return JsonRpcResponse::error(
573 id,
574 protocol::INVALID_PARAMS,
575 format!("invalid params: {e}"),
576 );
577 }
578 };
579 match self.resources.read(&read_params.uri).await {
580 Ok(output) => {
581 let res = protocol::ReadResourceResult {
582 contents: output.contents,
583 };
584 JsonRpcResponse::success(id, res)
585 }
586 Err(err) => JsonRpcResponse::error(id, protocol::INVALID_PARAMS, err.message),
587 }
588 }
589}
590
591// ── Response serialization ──────────────────────────────────────────
592
593/// A dispatched JSON-RPC result, ready to be inspected or rendered.
594///
595/// Returned by [`McpServer::handle_message`]. A `None` from that method means
596/// there is nothing to send (a notification, or a batch of only
597/// notifications); a `Some` is either a [`Single`](Self::Single) response
598/// object or a [`Batch`](Self::Batch) array.
599///
600/// `RpcOutcome` implements [`Serialize`](serde::Serialize) and
601/// [`Display`](core::fmt::Display), both rendering a `Single` as a JSON object
602/// and a `Batch` as a JSON array. Use
603/// [`to_wire`](Self::to_wire) (or `.to_string()`) for a `String`, or
604/// [`write_json`](Self::write_json) to append directly to a byte buffer — each
605/// serializes in a single pass with no intermediate [`serde_json::Value`].
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub enum RpcOutcome {
608 /// A single JSON-RPC response object.
609 Single(JsonRpcResponse),
610 /// A JSON-RPC batch response array (always non-empty).
611 Batch(Vec<JsonRpcResponse>),
612}
613
614impl serde::Serialize for RpcOutcome {
615 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
616 match self {
617 Self::Single(response) => response.serialize(serializer),
618 Self::Batch(responses) => responses.serialize(serializer),
619 }
620 }
621}
622
623impl RpcOutcome {
624 /// Render this outcome to a single JSON wire string.
625 ///
626 /// # Panics
627 ///
628 /// Panics only if a well-formed MCP response fails to serialize, which
629 /// would indicate a bug in this crate.
630 #[must_use]
631 pub fn to_wire(&self) -> String {
632 serde_json::to_string(self).expect("MCP response must be JSON-serializable")
633 }
634
635 /// Render this outcome's JSON wire form by appending it to `buf`.
636 ///
637 /// Lets callers reuse a single buffer across many responses, avoiding a
638 /// fresh allocation per message.
639 ///
640 /// # Panics
641 ///
642 /// Panics only if a well-formed MCP response fails to serialize, which
643 /// would indicate a bug in this crate.
644 pub fn write_json(&self, buf: &mut Vec<u8>) {
645 serde_json::to_writer(buf, self).expect("MCP response must be JSON-serializable");
646 }
647
648 /// Returns `true` if this is a [`Batch`](Self::Batch) of responses.
649 #[must_use]
650 pub const fn is_batch(&self) -> bool {
651 matches!(self, Self::Batch(_))
652 }
653
654 /// Consume the outcome into a flat list of responses.
655 ///
656 /// A [`Single`](Self::Single) yields a one-element `Vec`; a
657 /// [`Batch`](Self::Batch) yields its responses unchanged.
658 #[must_use]
659 pub fn into_responses(self) -> Vec<JsonRpcResponse> {
660 match self {
661 Self::Single(response) => vec![response],
662 Self::Batch(responses) => responses,
663 }
664 }
665}
666
667impl fmt::Display for RpcOutcome {
668 /// Renders the JSON wire form (object for `Single`, array for `Batch`).
669 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
670 f.write_str(&self.to_wire())
671 }
672}