dwara_core/ai/
mcp.rs

1//! MCP gateway (DW-087): a hand-rolled Model Context Protocol
2//! server/router over JSON-RPC 2.0.
3//!
4//! dwara exposes configured tools on a reserved HTTP path (default
5//! `/mcp`). The gateway is a ROUTER, not a tool executor: each tool
6//! names an upstream HTTP endpoint, and `tools/call` proxies the
7//! call (POST JSON body, get JSON response). AuthN is enforced on
8//! every request (the existing `security/authn` module); AuthZ is
9//! per-tool (the existing `security/authz` module). Agent sessions
10//! are state-store backed (the `mcp_sessions` table); tool calls are
11//! correlated in analytics (the `mcp_tool_calls` table).
12//!
13//! # Protocol
14//!
15//! MCP is JSON-RPC 2.0 over HTTP. The lifecycle:
16//!
17//! 1. `initialize` — the client sends its protocol version and
18//!    client info; the server creates a session, responds with its
19//!    protocol version, capabilities, and server info, and returns
20//!    the session id in the `Mcp-Session-Id` response header.
21//! 2. `notifications/initialized` — the client acknowledges (a
22//!    notification: no response).
23//! 3. `tools/list` — the server returns the tool definitions
24//!    (filtered by the caller's authz).
25//! 4. `tools/call` — the server authorizes, proxies the call to the
26//!    upstream, and returns the result.
27//! 5. `shutdown` — the server deletes the session.
28//!
29//! # Dependency direction
30//!
31//! `ai` depends on `config` only. The HTTP call reuses the same
32//! `hyper_util` client pattern as the DW-083 semantic cache and
33//! DW-085 routing policy (no new dependencies). The handler returns
34//! results; the dataplane records analytics/observability.
35
36use crate::config::ai::AiMcpConfig;
37use crate::config::{Authz, Gateway, UpstreamProtocol};
38use bytes::Bytes;
39use http_body_util::{BodyExt as _, Full};
40use hyper::{Method, Request};
41use hyper_util::client::legacy::Client;
42use hyper_util::rt::TokioExecutor;
43use serde_json::{json, Value};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, BTreeSet};
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::time::Duration;
48
49/// JSON-RPC 2.0 error codes (per the spec).
50mod error_code {
51    pub const PARSE_ERROR: i32 = -32700;
52    pub const INVALID_REQUEST: i32 = -32600;
53    pub const METHOD_NOT_FOUND: i32 = -32601;
54    #[allow(dead_code)]
55    pub const INVALID_PARAMS: i32 = -32602;
56    #[allow(dead_code)]
57    pub const INTERNAL_ERROR: i32 = -32603;
58}
59
60/// The MCP protocol version this server speaks.
61const PROTOCOL_VERSION: &str = "2025-06-18";
62
63/// The server name reported in the `initialize` response.
64const SERVER_NAME: &str = "dwara";
65
66/// Default session TTL in seconds (1 hour).
67const DEFAULT_TTL_SECS: u64 = 3600;
68
69/// Default max concurrent sessions.
70const DEFAULT_MAX_CONCURRENT: usize = 1000;
71
72/// Default upstream call timeout in milliseconds (30 s).
73const DEFAULT_TIMEOUT_MS: u64 = 30_000;
74
75/// Per-process session-id counter (disambiguates coarse clocks and
76/// same-nanosecond initializations). Session IDs are correlation
77/// handles with uniqueness, not cryptographic secrets — the sha256
78/// fold over wall-clock nanos + counter + consumer gives 128 bits of
79/// collision resistance per process.
80static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
81
82/// Generate a 128-bit hex session id: sha256 over (wall-clock nanos,
83/// process counter, consumer name), truncated to 32 hex chars (16
84/// bytes). Unique per process; no `rand` dependency (the codebase
85/// generates all its IDs this way — see `generate_request_id`).
86fn generate_session_id(consumer: &str) -> String {
87    let n = std::time::SystemTime::now()
88        .duration_since(std::time::UNIX_EPOCH)
89        .map(|d| d.as_nanos())
90        .unwrap_or(0);
91    let c = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
92    let mut hasher = Sha256::new();
93    hasher.update(n.to_le_bytes());
94    hasher.update(c.to_le_bytes());
95    hasher.update(consumer.as_bytes());
96    let hash = hasher.finalize();
97    // 16 bytes = 32 hex chars = 128 bits.
98    let hex: String = hash.iter().take(16).map(|b| format!("{b:02x}")).collect();
99    format!("mcp-{hex}")
100}
101
102/// One compiled MCP tool (DW-087): the resolved upstream URL, the
103/// HTTP method, the timeout, and the optional authz attachment.
104#[derive(Debug, Clone)]
105pub struct CompiledMcpTool {
106    /// The tool name (the key in `ai.mcp.tools`).
107    pub name: String,
108    /// Human-readable description (returned in `tools/list`).
109    pub description: String,
110    /// The JSON Schema for the tool's arguments (returned as
111    /// `inputSchema` in `tools/list`).
112    pub input_schema: Value,
113    /// The resolved upstream URL (scheme + host:port + path).
114    pub upstream_url: String,
115    /// The HTTP method for the upstream call.
116    pub method: String,
117    /// The upstream call timeout in milliseconds.
118    pub timeout_ms: u64,
119    /// Optional per-tool authorization. When present, the tool is
120    /// only callable by consumers satisfying the authz rules.
121    pub authz: Option<Authz>,
122}
123
124/// AI-11 (#200): One compiled MCP static resource. Served inline
125/// (no upstream proxy).
126#[derive(Debug, Clone)]
127pub struct CompiledMcpResource {
128    /// The resource name (the key in `ai.mcp.resources`).
129    pub name: String,
130    /// The resource URI.
131    pub uri: String,
132    /// Human-readable description.
133    pub description: String,
134    /// The MIME type of the content.
135    pub mime_type: String,
136    /// The resource content (returned verbatim in `resources/read`).
137    pub content: String,
138}
139
140/// AI-11 (#200): One compiled MCP static prompt template.
141#[derive(Debug, Clone)]
142pub struct CompiledMcpPrompt {
143    /// The prompt name (the key in `ai.mcp.prompts`).
144    pub name: String,
145    /// Human-readable description.
146    pub description: String,
147    /// The prompt template body (with `{{arg}}` placeholders).
148    pub template: String,
149    /// The argument definitions.
150    pub arguments: Vec<CompiledMcpPromptArgument>,
151}
152
153/// AI-11 (#200): One compiled prompt argument definition.
154#[derive(Debug, Clone)]
155pub struct CompiledMcpPromptArgument {
156    /// The argument name.
157    pub name: String,
158    /// Human-readable description.
159    pub description: String,
160    /// Whether the argument is required.
161    pub required: bool,
162}
163
164/// The compiled MCP gateway (DW-087): the tool table, session
165/// policy, and reserved path. Built at `AiRuntime` compile time
166/// from the `ai.mcp` config block; immutable once built.
167#[derive(Debug, Clone)]
168pub struct CompiledMcp {
169    /// The compiled tools, keyed by tool name.
170    pub tools: BTreeMap<String, CompiledMcpTool>,
171    /// AI-11 (#200): The compiled static resources, keyed by name.
172    pub resources: BTreeMap<String, CompiledMcpResource>,
173    /// AI-11 (#200): The compiled static prompts, keyed by name.
174    pub prompts: BTreeMap<String, CompiledMcpPrompt>,
175    /// Session TTL in seconds.
176    pub sessions_ttl_secs: u64,
177    /// Max concurrent sessions.
178    pub sessions_max_concurrent: usize,
179    /// The reserved HTTP path for the MCP endpoint.
180    pub path: String,
181    /// Per-consumer tool allowlists (DW-113): consumer name -> the
182    /// set of tool names that consumer may call. A consumer NOT in
183    /// this map has no restriction (may call any tool). Built from
184    /// `gateway.consumers[].tool_allowlist` at compile time.
185    pub consumer_tool_allowlists: BTreeMap<String, BTreeSet<String>>,
186}
187
188/// The outcome of an MCP tool call (DW-087): the result the dataplane
189/// records in analytics and returns to the client.
190#[derive(Debug, Clone)]
191pub struct McpToolCallOutcome {
192    /// The tool name that was called.
193    pub tool_name: String,
194    /// Whether the call was authorized.
195    pub allowed: bool,
196    /// The call duration in milliseconds.
197    pub duration_ms: f64,
198    /// An optional error code (for analytics).
199    pub error_code: Option<String>,
200    /// The status string: `success`, `error`, or `denied`.
201    pub status: String,
202}
203
204/// The result of handling one JSON-RPC request (DW-087): the
205/// response body (JSON) and optional analytics/observability
206/// outcomes the dataplane records.
207#[derive(Debug, Clone)]
208pub struct McpHandleResult {
209    /// The JSON-RPC response body (serialized JSON). `None` for
210    /// notifications (no response).
211    pub response: Option<Value>,
212    /// The session id associated with this request (the one used or
213    /// the one created by `initialize`).
214    pub session_id: Option<String>,
215    /// The tool call outcome (for `tools/call` only; the dataplane
216    /// records it in analytics).
217    pub tool_call: Option<McpToolCallOutcome>,
218    /// Whether a session was initialized (for the session counter
219    /// metric).
220    pub session_initialized: bool,
221    /// Whether a session was closed (for the session counter metric).
222    pub session_closed: bool,
223}
224
225impl CompiledMcp {
226    /// Compile from the `ai.mcp` config block. Resolves each tool's
227    /// upstream reference to a concrete URL using the gateway's
228    /// upstreams. Returns None when a referenced upstream is missing
229    /// or has no endpoints (a validate-vs-build race or an authoring
230    /// error validation missed — the tool is silently dropped with a
231    /// log).
232    pub fn compile(config: &AiMcpConfig, gateway: &Gateway) -> Option<Self> {
233        let sessions = config.sessions.as_ref();
234        let sessions_ttl_secs = sessions
235            .and_then(|s| s.ttl_secs)
236            .unwrap_or(DEFAULT_TTL_SECS);
237        let sessions_max_concurrent = sessions
238            .and_then(|s| s.max_concurrent)
239            .unwrap_or(DEFAULT_MAX_CONCURRENT);
240        let path = config.path.clone().unwrap_or_else(|| "/mcp".to_string());
241
242        let mut tools = BTreeMap::new();
243        let mut consumer_tool_allowlists: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
244        // DW-113: compile per-consumer tool allowlists from the gateway
245        // consumers. Only consumers with a non-empty tool_allowlist are
246        // inserted; a consumer absent from the map has no restriction.
247        for consumer in &gateway.consumers {
248            if !consumer.tool_allowlist.is_empty() {
249                consumer_tool_allowlists.insert(
250                    consumer.name.clone(),
251                    consumer.tool_allowlist.iter().cloned().collect(),
252                );
253            }
254        }
255        for (name, tool) in &config.tools {
256            let Some(upstream) = gateway.upstreams.iter().find(|u| u.name == tool.upstream) else {
257                tracing::warn!(
258                    code = "mcp_tool_upstream_missing",
259                    tool = %name,
260                    upstream = %tool.upstream,
261                    "mcp tool references unknown upstream; tool dropped"
262                );
263                continue;
264            };
265            let Some(endpoint) = upstream.endpoints.first() else {
266                tracing::warn!(
267                    code = "mcp_tool_upstream_no_endpoints",
268                    tool = %name,
269                    upstream = %tool.upstream,
270                    "mcp tool upstream has no endpoints; tool dropped"
271                );
272                continue;
273            };
274            let scheme = match upstream.protocol {
275                UpstreamProtocol::Http1 | UpstreamProtocol::Http2 | UpstreamProtocol::H2c => "http",
276                UpstreamProtocol::Https | UpstreamProtocol::H3 => "https",
277            };
278            let tool_path = tool.path.clone().unwrap_or_else(|| "/".to_string());
279            let upstream_url = format!(
280                "{scheme}://{}:{}{}",
281                endpoint.address, endpoint.port, tool_path
282            );
283            let method = tool
284                .method
285                .clone()
286                .unwrap_or_else(|| "POST".to_string())
287                .to_ascii_uppercase();
288            let timeout_ms = tool.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
289            tools.insert(
290                name.clone(),
291                CompiledMcpTool {
292                    name: name.clone(),
293                    description: tool.description.clone(),
294                    input_schema: tool.input_schema.clone(),
295                    upstream_url,
296                    method,
297                    timeout_ms,
298                    authz: tool.authz.clone(),
299                },
300            );
301        }
302        // AI-11 (#200): compile static resources.
303        let mut resources = BTreeMap::new();
304        for (name, resource) in &config.resources {
305            resources.insert(
306                name.clone(),
307                CompiledMcpResource {
308                    name: name.clone(),
309                    uri: resource.uri.clone(),
310                    description: resource.description.clone(),
311                    mime_type: resource
312                        .mime_type
313                        .clone()
314                        .unwrap_or_else(|| "text/plain".to_string()),
315                    content: resource.content.clone(),
316                },
317            );
318        }
319        // AI-11 (#200): compile static prompts.
320        let mut prompts = BTreeMap::new();
321        for (name, prompt) in &config.prompts {
322            let arguments = prompt
323                .arguments
324                .iter()
325                .map(|a| CompiledMcpPromptArgument {
326                    name: a.name.clone(),
327                    description: a.description.clone(),
328                    required: a.required,
329                })
330                .collect();
331            prompts.insert(
332                name.clone(),
333                CompiledMcpPrompt {
334                    name: name.clone(),
335                    description: prompt.description.clone(),
336                    template: prompt.template.clone(),
337                    arguments,
338                },
339            );
340        }
341        Some(CompiledMcp {
342            tools,
343            resources,
344            prompts,
345            sessions_ttl_secs,
346            sessions_max_concurrent,
347            path,
348            consumer_tool_allowlists,
349        })
350    }
351
352    /// The reserved HTTP path for the MCP endpoint.
353    pub fn path(&self) -> &str {
354        &self.path
355    }
356
357    /// The authz attachment for a tool (DW-087). Returns `None` when
358    /// the tool has no authz attachment (any authenticated consumer
359    /// may call it). The dataplane evaluates this attachment using
360    /// the `security::authz` module (the `ai` domain may not import
361    /// `security` — see `scripts/check_deps.py`).
362    pub fn tool_authz(&self, name: &str) -> Option<&Authz> {
363        self.tools.get(name).and_then(|t| t.authz.as_ref())
364    }
365
366    /// All tool names (DW-087), for the dataplane's authz-filtered
367    /// `tools/list` response.
368    pub fn tool_names(&self) -> impl Iterator<Item = &str> {
369        self.tools.keys().map(|s| s.as_str())
370    }
371
372    /// Whether `consumer` is allowed to call `tool` (DW-113). A
373    /// consumer NOT in the allowlist map has no restriction (may call
374    /// any tool); a consumer IN the map may call only the tools in its
375    /// set. The dataplane checks this BEFORE the authz attachment for
376    /// `tools/call` and filters `tools/list` by it.
377    pub fn consumer_allowed_tool(&self, consumer: &str, tool: &str) -> bool {
378        match self.consumer_tool_allowlists.get(consumer) {
379            None => true,
380            Some(allowed) => allowed.contains(tool),
381        }
382    }
383
384    /// Handle one JSON-RPC request. The `session_id` is extracted
385    /// from the `Mcp-Session-Id` header (None for `initialize`).
386    /// Returns the JSON-RPC response and optional analytics/observability
387    /// outcomes.
388    ///
389    /// **Authz is NOT evaluated here** — the `ai` domain may not
390    /// import `security`. The dataplane evaluates each tool's authz
391    /// attachment BEFORE calling this method for `tools/call`, and
392    /// filters the `tools/list` response after it returns.
393    ///
394    /// This method is async because `tools/call` makes an HTTP call
395    /// to the upstream.
396    pub async fn handle_request(
397        &self,
398        req: &JsonRpcRequest,
399        session_id: Option<&str>,
400        consumer: &str,
401    ) -> McpHandleResult {
402        // Notifications (no `id`) get no response.
403        let is_notification = req.id.is_none();
404        match req.method.as_str() {
405            "initialize" => self.handle_initialize(req, consumer, is_notification),
406            "notifications/initialized" => {
407                // A notification: no response.
408                McpHandleResult {
409                    response: None,
410                    session_id: session_id.map(|s| s.to_string()),
411                    tool_call: None,
412                    session_initialized: false,
413                    session_closed: false,
414                }
415            }
416            "tools/list" => self.handle_tools_list(req, is_notification),
417            "tools/call" => {
418                self.handle_tools_call(req, session_id, consumer, is_notification)
419                    .await
420            }
421            "resources/list" => self.handle_resources_list(req, is_notification),
422            "resources/read" => self.handle_resources_read(req, is_notification),
423            "prompts/list" => self.handle_prompts_list(req, is_notification),
424            "prompts/get" => self.handle_prompts_get(req, is_notification),
425            "shutdown" => self.handle_shutdown(req, session_id, is_notification),
426            _ => {
427                let response = if is_notification {
428                    None
429                } else {
430                    Some(json_rpc_error(
431                        req.id.clone(),
432                        error_code::METHOD_NOT_FOUND,
433                        "method not found",
434                    ))
435                };
436                McpHandleResult {
437                    response,
438                    session_id: session_id.map(|s| s.to_string()),
439                    tool_call: None,
440                    session_initialized: false,
441                    session_closed: false,
442                }
443            }
444        }
445    }
446
447    fn handle_initialize(
448        &self,
449        req: &JsonRpcRequest,
450        consumer: &str,
451        is_notification: bool,
452    ) -> McpHandleResult {
453        let session_id = generate_session_id(consumer);
454        let result = json!({
455            "protocolVersion": PROTOCOL_VERSION,
456            "capabilities": {
457                "tools": {}
458            },
459            "serverInfo": {
460                "name": SERVER_NAME,
461                "version": env!("CARGO_PKG_VERSION")
462            },
463            "sessionId": session_id,
464        });
465        let response = if is_notification {
466            None
467        } else {
468            Some(json_rpc_result(req.id.clone(), result))
469        };
470        McpHandleResult {
471            response,
472            session_id: Some(session_id),
473            tool_call: None,
474            session_initialized: true,
475            session_closed: false,
476        }
477    }
478
479    fn handle_tools_list(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
480        let mut tools_list = Vec::new();
481        for tool in self.tools.values() {
482            tools_list.push(json!({
483                "name": tool.name,
484                "description": tool.description,
485                "inputSchema": tool.input_schema,
486            }));
487        }
488        let result = json!({
489            "tools": tools_list,
490            "nextCursor": null,
491        });
492        let response = if is_notification {
493            None
494        } else {
495            Some(json_rpc_result(req.id.clone(), result))
496        };
497        McpHandleResult {
498            response,
499            session_id: None,
500            tool_call: None,
501            session_initialized: false,
502            session_closed: false,
503        }
504    }
505
506    /// AI-11 (#200): Handle `resources/list` — returns all compiled
507    /// static resources.
508    fn handle_resources_list(
509        &self,
510        req: &JsonRpcRequest,
511        is_notification: bool,
512    ) -> McpHandleResult {
513        let mut resources_list = Vec::new();
514        for resource in self.resources.values() {
515            resources_list.push(json!({
516                "uri": resource.uri,
517                "name": resource.name,
518                "description": resource.description,
519                "mimeType": resource.mime_type,
520            }));
521        }
522        let result = json!({
523            "resources": resources_list,
524            "nextCursor": null,
525        });
526        let response = if is_notification {
527            None
528        } else {
529            Some(json_rpc_result(req.id.clone(), result))
530        };
531        McpHandleResult {
532            response,
533            session_id: None,
534            tool_call: None,
535            session_initialized: false,
536            session_closed: false,
537        }
538    }
539
540    /// AI-11 (#200): Handle `resources/read` — returns the content of
541    /// the resource at the given URI.
542    fn handle_resources_read(
543        &self,
544        req: &JsonRpcRequest,
545        is_notification: bool,
546    ) -> McpHandleResult {
547        let params = req.params.clone().unwrap_or(Value::Null);
548        let uri = params.get("uri").and_then(|v| v.as_str()).unwrap_or("");
549        let resource = self.resources.values().find(|r| r.uri == uri);
550        let response = if is_notification {
551            None
552        } else if let Some(resource) = resource {
553            let result = json!({
554                "contents": [{
555                    "uri": resource.uri,
556                    "mimeType": resource.mime_type,
557                    "text": resource.content,
558                }]
559            });
560            Some(json_rpc_result(req.id.clone(), result))
561        } else {
562            Some(json_rpc_error(
563                req.id.clone(),
564                error_code::INVALID_PARAMS,
565                &format!("resource not found: {uri}"),
566            ))
567        };
568        McpHandleResult {
569            response,
570            session_id: None,
571            tool_call: None,
572            session_initialized: false,
573            session_closed: false,
574        }
575    }
576
577    /// AI-11 (#200): Handle `prompts/list` — returns all compiled
578    /// static prompts.
579    fn handle_prompts_list(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
580        let mut prompts_list = Vec::new();
581        for prompt in self.prompts.values() {
582            let arguments: Vec<Value> = prompt
583                .arguments
584                .iter()
585                .map(|a| {
586                    json!({
587                        "name": a.name,
588                        "description": a.description,
589                        "required": a.required,
590                    })
591                })
592                .collect();
593            prompts_list.push(json!({
594                "name": prompt.name,
595                "description": prompt.description,
596                "arguments": arguments,
597            }));
598        }
599        let result = json!({
600            "prompts": prompts_list,
601            "nextCursor": null,
602        });
603        let response = if is_notification {
604            None
605        } else {
606            Some(json_rpc_result(req.id.clone(), result))
607        };
608        McpHandleResult {
609            response,
610            session_id: None,
611            tool_call: None,
612            session_initialized: false,
613            session_closed: false,
614        }
615    }
616
617    /// AI-11 (#200): Handle `prompts/get` — returns the prompt
618    /// template with argument substitution.
619    fn handle_prompts_get(&self, req: &JsonRpcRequest, is_notification: bool) -> McpHandleResult {
620        let params = req.params.clone().unwrap_or(Value::Null);
621        let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
622        let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
623        let prompt = self.prompts.get(name);
624        let response = if is_notification {
625            None
626        } else if let Some(prompt) = prompt {
627            // Substitute {{arg}} placeholders with provided arguments.
628            let mut template = prompt.template.clone();
629            if let Some(args_obj) = arguments.as_object() {
630                for (key, value) in args_obj {
631                    if let Some(s) = value.as_str() {
632                        let placeholder = format!("{{{{{key}}}}}");
633                        template = template.replace(&placeholder, s);
634                    }
635                }
636            }
637            let result = json!({
638                "description": prompt.description,
639                "messages": [{
640                    "role": "user",
641                    "content": {
642                        "type": "text",
643                        "text": template,
644                    }
645                }]
646            });
647            Some(json_rpc_result(req.id.clone(), result))
648        } else {
649            Some(json_rpc_error(
650                req.id.clone(),
651                error_code::INVALID_PARAMS,
652                &format!("prompt not found: {name}"),
653            ))
654        };
655        McpHandleResult {
656            response,
657            session_id: None,
658            tool_call: None,
659            session_initialized: false,
660            session_closed: false,
661        }
662    }
663
664    async fn handle_tools_call(
665        &self,
666        req: &JsonRpcRequest,
667        session_id: Option<&str>,
668        _consumer: &str,
669        is_notification: bool,
670    ) -> McpHandleResult {
671        let started = std::time::Instant::now();
672        let params = req.params.clone().unwrap_or(Value::Null);
673        let tool_name = params
674            .get("name")
675            .and_then(|v| v.as_str())
676            .unwrap_or("")
677            .to_string();
678        let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
679
680        let Some(tool) = self.tools.get(&tool_name) else {
681            let outcome = McpToolCallOutcome {
682                tool_name: tool_name.clone(),
683                allowed: true,
684                duration_ms: started.elapsed().as_secs_f64() * 1000.0,
685                error_code: Some("tool_not_found".to_string()),
686                status: "error".to_string(),
687            };
688            let result = json!({
689                "content": [{"type": "text", "text": format!("unknown tool: {tool_name}")}],
690                "isError": true,
691            });
692            let response = if is_notification {
693                None
694            } else {
695                Some(json_rpc_result(req.id.clone(), result))
696            };
697            return McpHandleResult {
698                response,
699                session_id: session_id.map(|s| s.to_string()),
700                tool_call: Some(outcome),
701                session_initialized: false,
702                session_closed: false,
703            };
704        };
705
706        // Proxy the call to the upstream.
707        let call_result = call_upstream(tool, &arguments).await;
708        let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
709        match call_result {
710            Ok(text) => {
711                let outcome = McpToolCallOutcome {
712                    tool_name: tool_name.clone(),
713                    allowed: true,
714                    duration_ms,
715                    error_code: None,
716                    status: "success".to_string(),
717                };
718                let result = json!({
719                    "content": [{"type": "text", "text": text}],
720                    "isError": false,
721                });
722                let response = if is_notification {
723                    None
724                } else {
725                    Some(json_rpc_result(req.id.clone(), result))
726                };
727                McpHandleResult {
728                    response,
729                    session_id: session_id.map(|s| s.to_string()),
730                    tool_call: Some(outcome),
731                    session_initialized: false,
732                    session_closed: false,
733                }
734            }
735            Err(err) => {
736                let outcome = McpToolCallOutcome {
737                    tool_name: tool_name.clone(),
738                    allowed: true,
739                    duration_ms,
740                    error_code: Some("upstream_error".to_string()),
741                    status: "error".to_string(),
742                };
743                let result = json!({
744                    "content": [{"type": "text", "text": format!("upstream error: {err}")}],
745                    "isError": true,
746                });
747                let response = if is_notification {
748                    None
749                } else {
750                    Some(json_rpc_result(req.id.clone(), result))
751                };
752                McpHandleResult {
753                    response,
754                    session_id: session_id.map(|s| s.to_string()),
755                    tool_call: Some(outcome),
756                    session_initialized: false,
757                    session_closed: false,
758                }
759            }
760        }
761    }
762
763    fn handle_shutdown(
764        &self,
765        req: &JsonRpcRequest,
766        session_id: Option<&str>,
767        is_notification: bool,
768    ) -> McpHandleResult {
769        let response = if is_notification {
770            None
771        } else {
772            Some(json_rpc_result(req.id.clone(), json!({})))
773        };
774        McpHandleResult {
775            response,
776            session_id: session_id.map(|s| s.to_string()),
777            tool_call: None,
778            session_initialized: false,
779            session_closed: true,
780        }
781    }
782}
783
784/// Call the upstream HTTP endpoint for a tool (DW-087). Sends the
785/// tool's arguments as the JSON request body and returns the
786/// response body as text. Reuses the same `hyper_util` client
787/// pattern as the DW-085 routing policy classifier.
788async fn call_upstream(tool: &CompiledMcpTool, arguments: &Value) -> Result<String, String> {
789    let body_bytes =
790        serde_json::to_vec(arguments).map_err(|e| format!("encode request body: {e}"))?;
791    let method = match tool.method.as_str() {
792        "GET" => Method::GET,
793        "POST" => Method::POST,
794        "PUT" => Method::PUT,
795        "PATCH" => Method::PATCH,
796        "DELETE" => Method::DELETE,
797        _ => Method::POST,
798    };
799    let req = Request::builder()
800        .method(method)
801        .uri(&tool.upstream_url)
802        .header("content-type", "application/json")
803        .header("accept", "application/json")
804        .body(Full::new(Bytes::from(body_bytes)))
805        .map_err(|e| format!("build upstream request: {e}"))?;
806    let client = Client::builder(TokioExecutor::new()).build_http();
807    let timeout = Duration::from_millis(tool.timeout_ms);
808    let resp = tokio::time::timeout(timeout, client.request(req))
809        .await
810        .map_err(|_| format!("upstream timed out after {} ms", tool.timeout_ms))?
811        .map_err(|e| format!("upstream request failed: {e}"))?;
812    let status = resp.status();
813    let bytes = resp
814        .into_body()
815        .collect()
816        .await
817        .map_err(|e| format!("read upstream response body: {e}"))?
818        .to_bytes();
819    if !status.is_success() {
820        return Err(format!(
821            "upstream returned {status}: {}",
822            String::from_utf8_lossy(&bytes)
823        ));
824    }
825    Ok(String::from_utf8_lossy(&bytes).into_owned())
826}
827
828// --- JSON-RPC 2.0 types --------------------------------------------------
829
830/// One JSON-RPC 2.0 request (DW-087). `id` is None for notifications.
831#[derive(Debug, Clone)]
832pub struct JsonRpcRequest {
833    /// The `jsonrpc` version string (must be "2.0").
834    pub jsonrpc: String,
835    /// The request id (number, string, or null). None for
836    /// notifications (no response expected).
837    pub id: Option<Value>,
838    /// The method name.
839    pub method: String,
840    /// The optional params.
841    pub params: Option<Value>,
842}
843
844impl JsonRpcRequest {
845    /// Parse a JSON-RPC request from a JSON value. Returns an error
846    /// result Value when the input is not a valid JSON-RPC request.
847    pub fn parse(body: &Value) -> Result<Self, Value> {
848        let obj = body.as_object().ok_or_else(|| {
849            json_rpc_error(
850                None,
851                error_code::INVALID_REQUEST,
852                "request must be a JSON object",
853            )
854        })?;
855        let jsonrpc = obj.get("jsonrpc").and_then(|v| v.as_str()).ok_or_else(|| {
856            json_rpc_error(
857                None,
858                error_code::INVALID_REQUEST,
859                "missing or invalid 'jsonrpc' field",
860            )
861        })?;
862        if jsonrpc != "2.0" {
863            return Err(json_rpc_error(
864                None,
865                error_code::INVALID_REQUEST,
866                "unsupported jsonrpc version (must be '2.0')",
867            ));
868        }
869        let method = obj.get("method").and_then(|v| v.as_str()).ok_or_else(|| {
870            json_rpc_error(
871                None,
872                error_code::INVALID_REQUEST,
873                "missing or invalid 'method' field",
874            )
875        })?;
876        let id = obj.get("id").cloned();
877        let params = obj.get("params").cloned();
878        Ok(JsonRpcRequest {
879            jsonrpc: jsonrpc.to_string(),
880            id,
881            method: method.to_string(),
882            params,
883        })
884    }
885}
886
887/// Build a JSON-RPC success response (public, for the dataplane's
888/// authz-filtered tools/list and denied tools/call paths).
889pub fn json_rpc_result_pub(id: Option<Value>, result: Value) -> Value {
890    json_rpc_result(id, result)
891}
892
893/// Build a JSON-RPC success response.
894fn json_rpc_result(id: Option<Value>, result: Value) -> Value {
895    json!({
896        "jsonrpc": "2.0",
897        "id": id.unwrap_or(Value::Null),
898        "result": result,
899    })
900}
901
902/// Build a JSON-RPC error response.
903fn json_rpc_error(id: Option<Value>, code: i32, message: &str) -> Value {
904    json!({
905        "jsonrpc": "2.0",
906        "id": id.unwrap_or(Value::Null),
907        "error": {
908            "code": code,
909            "message": message,
910        },
911    })
912}
913
914/// Build a JSON-RPC parse error response (no id available).
915pub fn parse_error_response() -> Value {
916    json_rpc_error(None, error_code::PARSE_ERROR, "parse error")
917}
918
919/// Build a JSON-RPC error response (public, for the dataplane's
920/// session-management error paths).
921pub fn json_rpc_error_pub(id: Option<Value>, code: i32, message: &str) -> Value {
922    json_rpc_error(id, code, message)
923}