Skip to main content

relay_knowledge/interfaces/agent/
mcp.rs

1use std::{
2    error::Error,
3    fmt,
4    future::Future,
5    time::{Duration, Instant},
6};
7
8mod state;
9
10mod audit_bridge;
11mod code_tools;
12mod http_contract;
13mod metrics;
14mod prompts;
15mod resources;
16mod scope_authorization;
17mod tool_registry;
18
19use axum::{
20    Router,
21    body::Bytes,
22    extract::State,
23    http::{HeaderMap, HeaderValue, StatusCode, header},
24    response::{IntoResponse, Response},
25    routing::{get, post},
26};
27use serde::Deserialize;
28use serde_json::{Value, json};
29use tokio::sync::watch;
30use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
31
32use http_contract::{
33    ensure_remote_bind_allowed, validate_http_headers, validate_origin,
34    validate_protocol_version_header,
35};
36use scope_authorization::RuntimeScopeAuthorizer;
37use state::{CancellationRegistry, SessionCreateError, SessionLookupError, SessionRegistry};
38
39use crate::{
40    api::{
41        AgentRetrievalResult, ApiError, ErrorKind, GraphInspectionRequest, HybridRetrievalRequest,
42        InterfaceKind, RequestContext, RuntimeIdentity, freshness_label,
43    },
44    application::{AgentRuntimeConfig, RelayKnowledgeService},
45    domain::FreshnessPolicy,
46    net::{
47        NetworkRuntime,
48        http::HttpServeError,
49        qos::{QosPermit, QosRuntime, RejectReason},
50    },
51    observability::AgentProtocolMetrics,
52    project::PROJECT_NAME,
53};
54
55use super::{
56    AgentAdapterError, AgentAdapterErrorKind, AgentAuditEvent, AgentAuditLog, AgentAuditSink,
57    authorize_limit,
58};
59use audit_bridge::{record_mcp_qos_rejection, record_mcp_tool_audit};
60use code_tools::run_code_tool;
61use tool_registry::{
62    CODE_IMPACT_TOOL, CODE_QUERY_TOOL, HEALTH_TOOL, INDEX_STATUS_TOOL, INSPECT_GRAPH_TOOL,
63    RETRIEVE_CONTEXT_TOOL, SERVICE_STATUS_TOOL, is_known_tool, tools_list_result,
64};
65
66pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
67const MCP_PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
68const MCP_SESSION_ID_HEADER: &str = "mcp-session-id";
69
70/// MCP Streamable HTTP server state shared by route handlers.
71#[derive(Clone)]
72pub struct McpServer {
73    service: RelayKnowledgeService,
74    network: NetworkRuntime,
75    agent: AgentRuntimeConfig,
76    qos: QosRuntime,
77    audit: AgentAuditLog,
78    metrics: AgentProtocolMetrics,
79    cancellations: CancellationRegistry,
80    sessions: SessionRegistry,
81    scope_authorizer: RuntimeScopeAuthorizer,
82}
83
84impl McpServer {
85    /// Creates MCP server state from validated runtime boundaries.
86    pub fn new(
87        service: RelayKnowledgeService,
88        network: NetworkRuntime,
89        agent: AgentRuntimeConfig,
90    ) -> Self {
91        let metrics = service.observability().agent_metrics();
92        let audit = if agent.audit_sink_enabled {
93            AgentAuditSink::jsonl(service.agent_audit_log_path(), agent.audit_queue_depth)
94                .map(AgentAuditLog::with_sink)
95                .unwrap_or_default()
96        } else {
97            AgentAuditLog::default()
98        };
99
100        Self {
101            service,
102            network,
103            agent,
104            qos: QosRuntime::default(),
105            audit,
106            metrics,
107            cancellations: CancellationRegistry::default(),
108            sessions: SessionRegistry::default(),
109            scope_authorizer: RuntimeScopeAuthorizer::default(),
110        }
111    }
112
113    /// Builds the Streamable HTTP router without opening sockets.
114    pub fn router(self) -> Router {
115        let config = self.network.current();
116        let endpoint = self.agent.mcp_endpoint.clone();
117        let metrics_endpoint = metrics::metrics_endpoint(&endpoint);
118        let body_limit = usize::try_from(config.http.max_request_body_bytes).unwrap_or(usize::MAX);
119
120        Router::new()
121            .route(&endpoint, post(handle_mcp_post))
122            .route(&endpoint, axum::routing::delete(handle_mcp_delete))
123            .route(&metrics_endpoint, get(metrics::handle_metrics_get))
124            .with_state(self)
125            .layer(TraceLayer::new_for_http())
126            .layer(RequestBodyLimitLayer::new(body_limit))
127    }
128
129    /// Starts the MCP HTTP listener through `net::http`.
130    pub async fn serve_until_shutdown(
131        self,
132        shutdown: impl Future<Output = ()> + Send + 'static,
133    ) -> Result<(), McpServeError> {
134        let network_config = self.network.current();
135        let config = network_config.http;
136        let qos_policy = network_config.qos;
137        let qos = self.qos.clone();
138        let router = self.checked_router()?;
139
140        crate::net::http::serve_router_with_qos(router, config, qos, qos_policy, shutdown)
141            .await
142            .map_err(McpServeError::Http)
143    }
144
145    /// Builds the Streamable HTTP router after validating listener policy.
146    pub fn checked_router(self) -> Result<Router, McpServeError> {
147        if !self.agent.mcp_streamable_http_enabled {
148            return Err(McpServeError::Disabled);
149        }
150        ensure_remote_bind_allowed(&self.network.current().http, &self.agent.access_policy)?;
151
152        Ok(self.router())
153    }
154
155    #[cfg(test)]
156    pub fn qos_snapshot(&self) -> crate::net::qos::QosSnapshot {
157        self.qos.snapshot()
158    }
159
160    #[cfg(test)]
161    pub fn audit_snapshot(&self) -> Vec<AgentAuditEvent> {
162        self.audit.snapshot()
163    }
164}
165
166/// MCP server startup error.
167#[derive(Debug)]
168pub enum McpServeError {
169    Disabled,
170    RemoteBindDisabled,
171    Http(HttpServeError),
172}
173
174impl fmt::Display for McpServeError {
175    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::Disabled => write!(formatter, "MCP Streamable HTTP is not enabled"),
178            Self::RemoteBindDisabled => {
179                write!(
180                    formatter,
181                    "MCP remote bind requires allow_remote_clients=true"
182                )
183            }
184            Self::Http(error) => write!(formatter, "{error}"),
185        }
186    }
187}
188
189impl Error for McpServeError {}
190
191#[derive(Debug, Deserialize)]
192struct JsonRpcRequest {
193    jsonrpc: Option<String>,
194    id: Option<Value>,
195    method: Option<String>,
196    #[serde(default)]
197    params: Value,
198}
199
200#[derive(Debug, Deserialize)]
201struct InitializeParams {
202    #[serde(rename = "protocolVersion")]
203    protocol_version: String,
204    capabilities: Value,
205    #[serde(rename = "clientInfo")]
206    client_info: InitializeClientInfo,
207}
208
209#[derive(Debug, Deserialize)]
210struct InitializeClientInfo {
211    name: String,
212    version: String,
213}
214
215#[derive(Debug, Deserialize)]
216struct ToolCallParams {
217    name: String,
218    #[serde(default)]
219    arguments: Value,
220}
221
222#[derive(Debug, Deserialize)]
223struct RetrieveContextArgs {
224    query: String,
225    #[serde(default)]
226    source_scope: Option<String>,
227    #[serde(default)]
228    limit: Option<usize>,
229    #[serde(default)]
230    freshness: Option<String>,
231}
232
233#[derive(Debug, Deserialize)]
234struct InspectGraphArgs {
235    #[serde(default)]
236    source_scope: Option<String>,
237}
238
239#[derive(Debug, Deserialize)]
240struct CancelParams {
241    #[serde(rename = "requestId")]
242    request_id: Value,
243}
244
245pub(super) struct McpMethodError {
246    code: i64,
247    kind: &'static str,
248    message: String,
249}
250
251impl McpMethodError {
252    fn invalid_params(message: impl Into<String>) -> Self {
253        Self {
254            code: -32602,
255            kind: "invalid_argument",
256            message: message.into(),
257        }
258    }
259
260    fn internal(message: impl Into<String>) -> Self {
261        Self {
262            code: -32603,
263            kind: "internal",
264            message: message.into(),
265        }
266    }
267
268    fn timeout(message: impl Into<String>) -> Self {
269        Self {
270            code: -32000,
271            kind: "timeout",
272            message: message.into(),
273        }
274    }
275
276    fn api(error: ApiError) -> Self {
277        Self {
278            code: -32000,
279            kind: match error.error_kind {
280                ErrorKind::InvalidArgument => "invalid_argument",
281                ErrorKind::StorageUnavailable => "storage_unavailable",
282                ErrorKind::Timeout => "timeout",
283                ErrorKind::Internal => "internal",
284            },
285            message: error.message,
286        }
287    }
288
289    fn adapter(error: AgentAdapterError) -> Self {
290        Self {
291            code: -32000,
292            kind: error.kind.as_str(),
293            message: error.message,
294        }
295    }
296}
297
298async fn handle_mcp_post(
299    State(server): State<McpServer>,
300    headers: HeaderMap,
301    body: Bytes,
302) -> Response {
303    if let Err(status) = validate_http_headers(&server, &headers) {
304        return status.into_response();
305    }
306    if body.len() as u64 > server.network.current().http.max_request_body_bytes {
307        return StatusCode::PAYLOAD_TOO_LARGE.into_response();
308    }
309
310    let payload = match serde_json::from_slice::<Value>(&body) {
311        Ok(payload) => payload,
312        Err(error) => {
313            return json_rpc_error(Value::Null, -32700, format!("parse error: {error}"));
314        }
315    };
316    if payload.is_array() {
317        return json_rpc_error(Value::Null, -32600, "batch requests are not supported");
318    }
319    let request = match serde_json::from_value::<JsonRpcRequest>(payload.clone()) {
320        Ok(request) => request,
321        Err(error) => {
322            return json_rpc_error(Value::Null, -32600, format!("invalid request: {error}"));
323        }
324    };
325    let id = request.id.clone().unwrap_or(Value::Null);
326    if request.jsonrpc.as_deref() != Some("2.0") {
327        return json_rpc_error(id, -32600, "jsonrpc must be 2.0");
328    }
329    let Some(method) = request.method.as_deref() else {
330        if is_valid_json_rpc_response(&payload) {
331            if let Err(status) = validate_protocol_version_header(&headers, true) {
332                return status.into_response();
333            }
334            return response_message_session_response(&server, &headers);
335        }
336        if payload
337            .as_object()
338            .is_some_and(|object| object.contains_key("result") || object.contains_key("error"))
339        {
340            return StatusCode::BAD_REQUEST.into_response();
341        }
342        return json_rpc_error(id, -32600, "method is required");
343    };
344
345    if method == "initialize" {
346        let Some(id) = request.id else {
347            return json_rpc_error(Value::Null, -32600, "requests must include an id");
348        };
349        if !is_json_rpc_id(&id) {
350            return invalid_request_id_response();
351        }
352        if let Err(message) = validate_initialize_params(request.params) {
353            return json_rpc_error(id, -32602, message);
354        }
355        let Ok(permit) = admit_mcp_request(&server) else {
356            return StatusCode::TOO_MANY_REQUESTS.into_response();
357        };
358        let session_id = match server.sessions.require_session(&headers) {
359            Ok(session) => session.session_id().to_owned(),
360            Err(SessionLookupError::Missing) => match server.sessions.create_session() {
361                Ok(session_id) => session_id,
362                Err(error) => {
363                    drop(permit);
364                    return session_create_error(id, error);
365                }
366            },
367            Err(error) => {
368                drop(permit);
369                return session_lookup_error_response(error);
370            }
371        };
372        drop(permit);
373        return json_rpc_success_with_session(id, initialize_result(), &session_id);
374    }
375
376    if let Err(status) = validate_protocol_version_header(&headers, true) {
377        return status.into_response();
378    }
379
380    let session = match server.sessions.require_session(&headers) {
381        Ok(session) => session,
382        Err(error) => return session_lookup_error_response(error),
383    };
384
385    if method == "notifications/initialized" {
386        if request.id.is_some() {
387            return json_rpc_error(id, -32600, "notifications must not include an id");
388        }
389        let Ok(permit) = admit_mcp_request(&server) else {
390            return StatusCode::TOO_MANY_REQUESTS.into_response();
391        };
392        if let Err(error) = server.sessions.mark_initialized(session.session_id()) {
393            drop(permit);
394            return session_lookup_error_response(error);
395        }
396        drop(permit);
397        return StatusCode::ACCEPTED.into_response();
398    }
399
400    if !session.initialized {
401        return uninitialized_session_response(request.id);
402    }
403
404    let namespace = session.namespace();
405    if method.starts_with("notifications/") {
406        if request.id.is_some() {
407            return json_rpc_error(id, -32600, "notifications must not include an id");
408        }
409        let Ok(permit) = admit_mcp_request(&server) else {
410            return StatusCode::TOO_MANY_REQUESTS.into_response();
411        };
412        handle_notification(&server, method, request.params, &namespace);
413        drop(permit);
414        return StatusCode::ACCEPTED.into_response();
415    }
416
417    let Some(id) = request.id else {
418        return json_rpc_error(Value::Null, -32600, "requests must include an id");
419    };
420    let Some(request_id) = request_id_key(&namespace, &id) else {
421        return invalid_request_id_response();
422    };
423    let permit = match admit_mcp_request(&server) {
424        Ok(permit) => permit,
425        Err(reason) => {
426            let error =
427                AgentAdapterError::new(AgentAdapterErrorKind::QosRejected, qos_message(reason));
428            record_mcp_qos_rejection(&server, method, &id, error.kind.as_str());
429            server.metrics.record_rejection("mcp", error.kind.as_str());
430            return if method == "tools/call" {
431                json_rpc_success(id, tool_error_result(error))
432            } else {
433                json_rpc_error(id, -32000, error.to_string())
434            };
435        }
436    };
437
438    let started = Instant::now();
439    let mut pending_tool_audit = None;
440    let result = match method {
441        "ping" => json!({}),
442        "tools/list" => json!(tools_list_result()),
443        "resources/list" => resources::list_resources(&server),
444        "resources/read" => {
445            match resources::read_resource_with_timeout(&server, request.params, &request_id).await
446            {
447                Ok(result) => result,
448                Err(error) => return json_rpc_error(id, error.code, error.message),
449            }
450        }
451        "prompts/list" => prompts::list_prompts(),
452        "prompts/get" => match prompts::get_prompt(&server, request.params, &request_id).await {
453            Ok(result) => result,
454            Err(error) => return json_rpc_error(id, error.code, error.message),
455        },
456        "tools/call" => {
457            let params = match serde_json::from_value::<ToolCallParams>(request.params) {
458                Ok(params) => params,
459                Err(error) => {
460                    return json_rpc_error(
461                        id,
462                        -32602,
463                        format!("invalid tools/call params: {error}"),
464                    );
465                }
466            };
467            if !is_known_tool(&params.name) {
468                return json_rpc_error(id, -32602, "unknown tool name");
469            }
470            let outcome = run_cancellable_tool_call(&server, params, request_id).await;
471            pending_tool_audit = Some((
472                outcome.operation,
473                outcome.request_id,
474                outcome.result.clone(),
475                outcome.duration_ms,
476            ));
477            outcome.result
478        }
479        _ => return json_rpc_error(id, -32601, "method not found"),
480    };
481
482    drop(permit);
483    if let Some((operation, request_id, result, duration_ms)) = pending_tool_audit {
484        record_mcp_tool_audit(&server, &operation, &request_id, &result, duration_ms).await;
485    } else if !matches!(method, "resources/read" | "prompts/get") {
486        server
487            .metrics
488            .record_request("mcp", method, "completed", elapsed_millis(started), false);
489    }
490    json_rpc_success(id, result)
491}
492
493async fn handle_mcp_delete(State(server): State<McpServer>, headers: HeaderMap) -> Response {
494    if let Err(status) = validate_origin(&server, &headers) {
495        return status.into_response();
496    }
497    if let Err(status) = validate_protocol_version_header(&headers, true) {
498        return status.into_response();
499    }
500    let permit = match admit_mcp_request(&server) {
501        Ok(permit) => permit,
502        Err(_) => {
503            server.metrics.record_rejection("mcp", "qos_rejected");
504            return StatusCode::TOO_MANY_REQUESTS.into_response();
505        }
506    };
507    match server.sessions.terminate_session(&headers) {
508        Ok(()) => {
509            drop(permit);
510            StatusCode::ACCEPTED.into_response()
511        }
512        Err(error) => {
513            drop(permit);
514            session_lookup_error_response(error)
515        }
516    }
517}
518
519fn is_valid_json_rpc_response(payload: &Value) -> bool {
520    let Some(object) = payload.as_object() else {
521        return false;
522    };
523    let has_result = object.contains_key("result");
524    let has_error = object.contains_key("error");
525    if has_result == has_error {
526        return false;
527    }
528
529    object.get("id").is_some_and(is_json_rpc_id)
530}
531
532fn validate_initialize_params(params: Value) -> Result<(), String> {
533    let params = serde_json::from_value::<InitializeParams>(params)
534        .map_err(|error| format!("invalid initialize params: {error}"))?;
535    if params.protocol_version != MCP_PROTOCOL_VERSION {
536        return Err(format!(
537            "unsupported MCP protocol version '{}'",
538            params.protocol_version
539        ));
540    }
541    if !params.capabilities.is_object() {
542        return Err("initialize capabilities must be an object".to_owned());
543    }
544    if params.client_info.name.trim().is_empty() || params.client_info.version.trim().is_empty() {
545        return Err("initialize clientInfo requires name and version".to_owned());
546    }
547
548    Ok(())
549}
550
551fn response_message_session_response(server: &McpServer, headers: &HeaderMap) -> Response {
552    match server.sessions.require_session(headers) {
553        Ok(session) if session.initialized => StatusCode::ACCEPTED.into_response(),
554        Ok(_) => StatusCode::BAD_REQUEST.into_response(),
555        Err(error) => session_lookup_error_response(error),
556    }
557}
558
559fn session_lookup_error_response(error: SessionLookupError) -> Response {
560    match error {
561        SessionLookupError::Missing | SessionLookupError::InvalidHeader => {
562            StatusCode::BAD_REQUEST.into_response()
563        }
564        SessionLookupError::Unknown => StatusCode::NOT_FOUND.into_response(),
565    }
566}
567
568fn uninitialized_session_response(id: Option<Value>) -> Response {
569    let Some(id) = id else {
570        return StatusCode::BAD_REQUEST.into_response();
571    };
572    if is_json_rpc_id(&id) {
573        json_rpc_error(id, -32002, "MCP session is not initialized")
574    } else {
575        invalid_request_id_response()
576    }
577}
578
579fn invalid_request_id_response() -> Response {
580    json_rpc_error(Value::Null, -32600, "request id must be a string or number")
581}
582
583fn session_create_error(id: Value, error: SessionCreateError) -> Response {
584    json_rpc_error(id, -32603, format!("failed to create MCP session: {error}"))
585}
586
587fn handle_notification(server: &McpServer, method: &str, params: Value, namespace: &str) {
588    if method == "notifications/cancelled" {
589        if let Ok(cancel) = serde_json::from_value::<CancelParams>(params) {
590            if let Some(request_id) = request_id_key(namespace, &cancel.request_id) {
591                server.cancellations.cancel(&request_id);
592            }
593        }
594    }
595}
596
597fn admit_mcp_request(server: &McpServer) -> Result<QosPermit, RejectReason> {
598    let policy = server.network.current().qos;
599    let queued = server.qos.reserve_queue(&policy)?;
600    let permit = server.qos.admit_request(&policy);
601    drop(queued);
602    permit
603}
604
605async fn run_cancellable_tool_call(
606    server: &McpServer,
607    params: ToolCallParams,
608    request_id: String,
609) -> ToolCallOutcome {
610    let started = Instant::now();
611    let operation = params.name.clone();
612    let (mut cancellation, _registration) = server.cancellations.register(request_id.clone());
613    let timeout = Duration::from_millis(server.agent.access_policy.max_runtime_ms);
614    let tool = run_tool_call(server, params, request_id.clone());
615
616    let result = tokio::select! {
617        result = tokio::time::timeout(timeout, tool) => match result {
618            Ok(value) => value,
619            Err(_) => tool_error_result(AgentAdapterError::new(
620                AgentAdapterErrorKind::Timeout,
621                "MCP tool call exceeded max_runtime_ms",
622            )),
623        },
624        _ = wait_for_cancellation(&mut cancellation) => {
625            tool_error_result(AgentAdapterError::new(
626                AgentAdapterErrorKind::Cancelled,
627                "MCP tool call was cancelled",
628            ))
629        }
630    };
631
632    ToolCallOutcome {
633        operation,
634        request_id,
635        result,
636        duration_ms: elapsed_millis(started),
637    }
638}
639
640struct ToolCallOutcome {
641    operation: String,
642    request_id: String,
643    result: Value,
644    duration_ms: u64,
645}
646
647async fn wait_for_cancellation(cancellation: &mut watch::Receiver<bool>) {
648    while cancellation.changed().await.is_ok() {
649        if *cancellation.borrow() {
650            return;
651        }
652    }
653
654    std::future::pending::<()>().await;
655}
656
657async fn run_tool_call(server: &McpServer, params: ToolCallParams, request_id: String) -> Value {
658    match params.name.as_str() {
659        RETRIEVE_CONTEXT_TOOL => retrieve_context_tool(server, params.arguments, request_id).await,
660        INSPECT_GRAPH_TOOL => inspect_graph_tool(server, params.arguments, request_id).await,
661        HEALTH_TOOL => health_tool(server, request_id).await,
662        SERVICE_STATUS_TOOL => service_status_tool(server, request_id).await,
663        INDEX_STATUS_TOOL => index_status_tool(server, request_id).await,
664        CODE_QUERY_TOOL | CODE_IMPACT_TOOL => {
665            run_code_tool(server, params.name.as_str(), params.arguments, request_id).await
666        }
667        _ => json!({
668            "content": [{"type": "text", "text": "unknown MCP tool"}],
669            "isError": true
670        }),
671    }
672}
673
674async fn retrieve_context_tool(server: &McpServer, arguments: Value, request_id: String) -> Value {
675    let started = Instant::now();
676    let args = match serde_json::from_value::<RetrieveContextArgs>(arguments) {
677        Ok(args) => args,
678        Err(error) => return tool_error_result(invalid_arguments(error)),
679    };
680    let policy = &server.agent.access_policy;
681    let limit = match authorize_limit(args.limit, policy) {
682        Ok(limit) => limit,
683        Err(error) => return tool_error_result(error),
684    };
685    let source_scope = match server
686        .scope_authorizer
687        .authorize_scope(&server.service, policy, args.source_scope)
688        .await
689    {
690        Ok(scope) => scope,
691        Err(error) => return tool_error_result(error),
692    };
693    let freshness = match parse_freshness(args.freshness.as_deref()) {
694        Ok(freshness) => freshness,
695        Err(error) => return tool_error_result(error),
696    };
697    let context = request_context(request_id.clone());
698    let identity = RuntimeIdentity::mcp(Some(request_id));
699
700    match server
701        .service
702        .retrieve_context(
703            HybridRetrievalRequest {
704                query: args.query,
705                source_scope: source_scope.clone(),
706                limit,
707                freshness,
708            },
709            context,
710        )
711        .await
712    {
713        Ok(response) => {
714            let elapsed_ms = elapsed_millis(started);
715            let result = AgentRetrievalResult::from_retrieval(
716                response,
717                identity,
718                policy.max_context_bytes,
719                elapsed_ms,
720            );
721            tool_success_result(
722                format!(
723                    "retrieved {} result(s), graph_version={}, freshness={}",
724                    result.results.len(),
725                    result.metadata.graph_version,
726                    freshness_label(freshness)
727                ),
728                json!(result),
729            )
730        }
731        Err(error) => api_error_result(error),
732    }
733}
734
735async fn inspect_graph_tool(server: &McpServer, arguments: Value, request_id: String) -> Value {
736    let args = match serde_json::from_value::<InspectGraphArgs>(arguments) {
737        Ok(args) => args,
738        Err(error) => return tool_error_result(invalid_arguments(error)),
739    };
740    let source_scope = match server
741        .scope_authorizer
742        .authorize_scope(
743            &server.service,
744            &server.agent.access_policy,
745            args.source_scope,
746        )
747        .await
748    {
749        Ok(scope) => scope,
750        Err(error) => return tool_error_result(error),
751    };
752
753    match server
754        .service
755        .inspect_graph(
756            GraphInspectionRequest { source_scope },
757            request_context(request_id),
758        )
759        .await
760    {
761        Ok(response) => tool_success_result("graph inspection completed", json!(response)),
762        Err(error) => api_error_result(error),
763    }
764}
765
766async fn health_tool(server: &McpServer, request_id: String) -> Value {
767    match server.service.health(request_context(request_id)).await {
768        Ok(response) => tool_success_result(
769            format!(
770                "health={}",
771                if response.healthy { "ok" } else { "degraded" }
772            ),
773            json!(response),
774        ),
775        Err(error) => api_error_result(error),
776    }
777}
778
779async fn service_status_tool(server: &McpServer, request_id: String) -> Value {
780    match server
781        .service
782        .service_status(request_context(request_id))
783        .await
784    {
785        Ok(response) => tool_success_result("service status loaded", json!(response)),
786        Err(error) => api_error_result(error),
787    }
788}
789
790async fn index_status_tool(server: &McpServer, request_id: String) -> Value {
791    match server.service.health(request_context(request_id)).await {
792        Ok(response) => tool_success_result(
793            "index status loaded",
794            json!({
795                "metadata": response.metadata,
796                "indexes": response.indexes,
797            }),
798        ),
799        Err(error) => api_error_result(error),
800    }
801}
802
803fn initialize_result() -> Value {
804    json!({
805        "protocolVersion": MCP_PROTOCOL_VERSION,
806        "capabilities": {
807            "tools": {},
808            "resources": {"listChanged": false},
809            "prompts": {"listChanged": false}
810        },
811        "serverInfo": {
812            "name": PROJECT_NAME,
813            "version": env!("CARGO_PKG_VERSION")
814        }
815    })
816}
817
818fn parse_freshness(value: Option<&str>) -> Result<FreshnessPolicy, AgentAdapterError> {
819    match value.unwrap_or("allow-stale") {
820        "allow-stale" => Ok(FreshnessPolicy::AllowStale),
821        "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
822        "graph-only" => Ok(FreshnessPolicy::GraphOnly),
823        other => Err(AgentAdapterError::new(
824            AgentAdapterErrorKind::InvalidArgument,
825            format!("invalid freshness '{other}'"),
826        )),
827    }
828}
829
830fn tool_success_result(summary: impl Into<String>, structured_content: Value) -> Value {
831    json!({
832        "content": [{"type": "text", "text": summary.into()}],
833        "structuredContent": structured_content,
834        "isError": false
835    })
836}
837
838fn api_error_result(error: ApiError) -> Value {
839    tool_error_result(AgentAdapterError::new(
840        agent_error_kind(error.error_kind),
841        error.message,
842    ))
843}
844
845fn agent_error_kind(kind: ErrorKind) -> AgentAdapterErrorKind {
846    match kind {
847        ErrorKind::InvalidArgument => AgentAdapterErrorKind::InvalidArgument,
848        ErrorKind::StorageUnavailable => AgentAdapterErrorKind::StorageUnavailable,
849        ErrorKind::Timeout => AgentAdapterErrorKind::Timeout,
850        ErrorKind::Internal => AgentAdapterErrorKind::Internal,
851    }
852}
853
854fn tool_error_result(error: AgentAdapterError) -> Value {
855    json!({
856        "content": [{
857            "type": "text",
858            "text": format!("{}: {}", error.kind.as_str(), error.message)
859        }],
860        "structuredContent": {
861            "error_kind": error.kind.as_str(),
862            "message": error.message,
863        },
864        "isError": true
865    })
866}
867
868fn invalid_arguments(error: serde_json::Error) -> AgentAdapterError {
869    AgentAdapterError::new(
870        AgentAdapterErrorKind::InvalidArgument,
871        format!("invalid tool arguments: {error}"),
872    )
873}
874
875fn domain_argument_error(error: impl fmt::Display) -> AgentAdapterError {
876    AgentAdapterError::new(AgentAdapterErrorKind::InvalidArgument, error.to_string())
877}
878
879fn json_rpc_success(id: Value, result: Value) -> Response {
880    json_response(
881        StatusCode::OK,
882        json!({ "jsonrpc": "2.0", "id": id, "result": result }),
883    )
884}
885
886fn json_rpc_success_with_session(id: Value, result: Value, session_id: &str) -> Response {
887    let mut response = json_rpc_success(id, result);
888    response.headers_mut().insert(
889        MCP_SESSION_ID_HEADER,
890        HeaderValue::from_str(session_id).expect("generated MCP session id is a valid header"),
891    );
892    response
893}
894
895fn json_rpc_error(id: Value, code: i64, message: impl Into<String>) -> Response {
896    json_response(
897        StatusCode::OK,
898        json!({
899            "jsonrpc": "2.0",
900            "id": id,
901            "error": {
902                "code": code,
903                "message": message.into()
904            }
905        }),
906    )
907}
908
909fn json_response(status: StatusCode, value: Value) -> Response {
910    (
911        status,
912        [(header::CONTENT_TYPE, "application/json")],
913        value.to_string(),
914    )
915        .into_response()
916}
917
918fn request_context(request_id: String) -> RequestContext {
919    RequestContext::with_ids(
920        InterfaceKind::Mcp,
921        format!("mcp-{request_id}"),
922        format!("trace-mcp-{request_id}"),
923    )
924}
925
926fn endpoint_child(endpoint: &str, child: &str) -> String {
927    if endpoint == "/" {
928        format!("/{child}")
929    } else {
930        format!("{}/{child}", endpoint.trim_end_matches('/'))
931    }
932}
933
934fn request_id_key(namespace: &str, value: &Value) -> Option<String> {
935    match value {
936        Value::String(value) => Some(format!("{namespace}|string:{value}")),
937        Value::Number(value) if value.is_i64() || value.is_u64() => {
938            Some(format!("{namespace}|number:{value}"))
939        }
940        _ => None,
941    }
942}
943
944fn is_json_rpc_id(value: &Value) -> bool {
945    match value {
946        Value::String(_) => true,
947        Value::Number(number) => number.is_i64() || number.is_u64(),
948        _ => false,
949    }
950}
951
952fn elapsed_millis(started: Instant) -> u64 {
953    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
954}
955
956fn qos_message(reason: RejectReason) -> &'static str {
957    match reason {
958        RejectReason::ConnectionBudgetExceeded => "connection budget exhausted",
959        RejectReason::RequestBudgetExceeded => "request budget exhausted",
960        RejectReason::QueueBudgetExceeded => "queue budget exhausted",
961    }
962}
963
964#[cfg(test)]
965#[path = "mcp_test_support.rs"]
966mod mcp_test_support;
967
968#[cfg(test)]
969#[path = "mcp_tests.rs"]
970mod mcp_tests;
971
972#[cfg(test)]
973#[path = "mcp_tool_tests.rs"]
974mod mcp_tool_tests;
975
976#[cfg(test)]
977#[path = "mcp_protocol_tests.rs"]
978mod mcp_protocol_tests;