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