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    CODE_SOFTWARE_QUERY_TOOL, HEALTH_TOOL, INDEX_STATUS_TOOL, INSPECT_GRAPH_TOOL,
64    RETRIEVE_CONTEXT_TOOL, SERVICE_STATUS_TOOL, 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    server.qos.admit_queued_request(&policy)
601}
602
603async fn run_cancellable_tool_call(
604    server: &McpServer,
605    params: ToolCallParams,
606    request_id: String,
607) -> ToolCallOutcome {
608    let started = Instant::now();
609    let operation = params.name.clone();
610    let (mut cancellation, _registration) = server.cancellations.register(request_id.clone());
611    let timeout = Duration::from_millis(server.agent.access_policy.max_runtime_ms);
612    let tool = run_tool_call(server, params, request_id.clone());
613
614    let result = tokio::select! {
615        result = tokio::time::timeout(timeout, tool) => match result {
616            Ok(value) => value,
617            Err(_) => tool_error_result(AgentAdapterError::new(
618                AgentAdapterErrorKind::Timeout,
619                "MCP tool call exceeded max_runtime_ms",
620            )),
621        },
622        _ = wait_for_cancellation(&mut cancellation) => {
623            tool_error_result(AgentAdapterError::new(
624                AgentAdapterErrorKind::Cancelled,
625                "MCP tool call was cancelled",
626            ))
627        }
628    };
629
630    ToolCallOutcome {
631        operation,
632        request_id,
633        result,
634        duration_ms: elapsed_millis(started),
635    }
636}
637
638struct ToolCallOutcome {
639    operation: String,
640    request_id: String,
641    result: Value,
642    duration_ms: u64,
643}
644
645async fn wait_for_cancellation(cancellation: &mut watch::Receiver<bool>) {
646    while cancellation.changed().await.is_ok() {
647        if *cancellation.borrow() {
648            return;
649        }
650    }
651
652    std::future::pending::<()>().await;
653}
654
655async fn run_tool_call(server: &McpServer, params: ToolCallParams, request_id: String) -> Value {
656    match params.name.as_str() {
657        RETRIEVE_CONTEXT_TOOL => retrieve_context_tool(server, params.arguments, request_id).await,
658        INSPECT_GRAPH_TOOL => inspect_graph_tool(server, params.arguments, request_id).await,
659        HEALTH_TOOL => health_tool(server, request_id).await,
660        SERVICE_STATUS_TOOL => service_status_tool(server, request_id).await,
661        INDEX_STATUS_TOOL => index_status_tool(server, request_id).await,
662        CODE_QUERY_TOOL
663        | CODE_FEATURE_FLAGS_TOOL
664        | CODE_IMPACT_TOOL
665        | CODE_REPOSITORY_SET_QUERY_TOOL
666        | CODE_SOFTWARE_QUERY_TOOL => {
667            run_code_tool(server, params.name.as_str(), params.arguments, request_id).await
668        }
669        _ => json!({
670            "content": [{"type": "text", "text": "unknown MCP tool"}],
671            "isError": true
672        }),
673    }
674}
675
676async fn retrieve_context_tool(server: &McpServer, arguments: Value, request_id: String) -> Value {
677    let started = Instant::now();
678    let args = match serde_json::from_value::<RetrieveContextArgs>(arguments) {
679        Ok(args) => args,
680        Err(error) => return tool_error_result(invalid_arguments(error)),
681    };
682    let policy = &server.agent.access_policy;
683    let limit = match authorize_limit(args.limit, policy) {
684        Ok(limit) => limit,
685        Err(error) => return tool_error_result(error),
686    };
687    let source_scope = match server
688        .scope_authorizer
689        .authorize_scope(&server.service, policy, args.source_scope)
690        .await
691    {
692        Ok(scope) => scope,
693        Err(error) => return tool_error_result(error),
694    };
695    let freshness = match parse_freshness(args.freshness.as_deref()) {
696        Ok(freshness) => freshness,
697        Err(error) => return tool_error_result(error),
698    };
699    let context = request_context(request_id.clone());
700    let identity = RuntimeIdentity::mcp(Some(request_id));
701
702    match server
703        .service
704        .retrieve_context(
705            HybridRetrievalRequest {
706                query: args.query,
707                source_scope: source_scope.clone(),
708                limit,
709                freshness,
710            },
711            context,
712        )
713        .await
714    {
715        Ok(response) => {
716            let elapsed_ms = elapsed_millis(started);
717            let result = AgentRetrievalResult::from_retrieval(
718                response,
719                identity,
720                policy.max_context_bytes,
721                elapsed_ms,
722            );
723            tool_success_result(
724                format!(
725                    "retrieved {} result(s), graph_version={}, freshness={}",
726                    result.results.len(),
727                    result.metadata.graph_version,
728                    freshness_label(freshness)
729                ),
730                json!(result),
731            )
732        }
733        Err(error) => api_error_result(error),
734    }
735}
736
737async fn inspect_graph_tool(server: &McpServer, arguments: Value, request_id: String) -> Value {
738    let args = match serde_json::from_value::<InspectGraphArgs>(arguments) {
739        Ok(args) => args,
740        Err(error) => return tool_error_result(invalid_arguments(error)),
741    };
742    let source_scope = match server
743        .scope_authorizer
744        .authorize_scope(
745            &server.service,
746            &server.agent.access_policy,
747            args.source_scope,
748        )
749        .await
750    {
751        Ok(scope) => scope,
752        Err(error) => return tool_error_result(error),
753    };
754
755    match server
756        .service
757        .inspect_graph(
758            GraphInspectionRequest { source_scope },
759            request_context(request_id),
760        )
761        .await
762    {
763        Ok(response) => tool_success_result("graph inspection completed", json!(response)),
764        Err(error) => api_error_result(error),
765    }
766}
767
768async fn health_tool(server: &McpServer, request_id: String) -> Value {
769    match server.service.health(request_context(request_id)).await {
770        Ok(response) => tool_success_result(
771            format!(
772                "health={}",
773                if response.healthy { "ok" } else { "degraded" }
774            ),
775            json!(response),
776        ),
777        Err(error) => api_error_result(error),
778    }
779}
780
781async fn service_status_tool(server: &McpServer, request_id: String) -> Value {
782    match server
783        .service
784        .service_status(request_context(request_id))
785        .await
786    {
787        Ok(response) => tool_success_result("service status loaded", json!(response)),
788        Err(error) => api_error_result(error),
789    }
790}
791
792async fn index_status_tool(server: &McpServer, request_id: String) -> Value {
793    match server.service.health(request_context(request_id)).await {
794        Ok(response) => tool_success_result(
795            "index status loaded",
796            json!({
797                "metadata": response.metadata,
798                "indexes": response.indexes,
799            }),
800        ),
801        Err(error) => api_error_result(error),
802    }
803}
804
805fn initialize_result() -> Value {
806    json!({
807        "protocolVersion": MCP_PROTOCOL_VERSION,
808        "capabilities": {
809            "tools": {},
810            "resources": {"listChanged": false},
811            "prompts": {"listChanged": false}
812        },
813        "serverInfo": {
814            "name": PROJECT_NAME,
815            "version": env!("CARGO_PKG_VERSION")
816        }
817    })
818}
819
820fn parse_freshness(value: Option<&str>) -> Result<FreshnessPolicy, AgentAdapterError> {
821    match value.unwrap_or("allow-stale") {
822        "allow-stale" => Ok(FreshnessPolicy::AllowStale),
823        "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
824        "graph-only" => Ok(FreshnessPolicy::GraphOnly),
825        other => Err(AgentAdapterError::new(
826            AgentAdapterErrorKind::InvalidArgument,
827            format!("invalid freshness '{other}'"),
828        )),
829    }
830}
831
832fn tool_success_result(summary: impl Into<String>, structured_content: Value) -> Value {
833    json!({
834        "content": [{"type": "text", "text": summary.into()}],
835        "structuredContent": structured_content,
836        "isError": false
837    })
838}
839
840fn api_error_result(error: ApiError) -> Value {
841    tool_error_result(AgentAdapterError::new(
842        agent_error_kind(error.error_kind),
843        error.message,
844    ))
845}
846
847fn agent_error_kind(kind: ErrorKind) -> AgentAdapterErrorKind {
848    match kind {
849        ErrorKind::InvalidArgument => AgentAdapterErrorKind::InvalidArgument,
850        ErrorKind::StorageUnavailable => AgentAdapterErrorKind::StorageUnavailable,
851        ErrorKind::Timeout => AgentAdapterErrorKind::Timeout,
852        ErrorKind::Internal => AgentAdapterErrorKind::Internal,
853    }
854}
855
856fn tool_error_result(error: AgentAdapterError) -> Value {
857    json!({
858        "content": [{
859            "type": "text",
860            "text": format!("{}: {}", error.kind.as_str(), error.message)
861        }],
862        "structuredContent": {
863            "error_kind": error.kind.as_str(),
864            "message": error.message,
865        },
866        "isError": true
867    })
868}
869
870fn invalid_arguments(error: serde_json::Error) -> AgentAdapterError {
871    AgentAdapterError::new(
872        AgentAdapterErrorKind::InvalidArgument,
873        format!("invalid tool arguments: {error}"),
874    )
875}
876
877fn domain_argument_error(error: impl fmt::Display) -> AgentAdapterError {
878    AgentAdapterError::new(AgentAdapterErrorKind::InvalidArgument, error.to_string())
879}
880
881fn json_rpc_success(id: Value, result: Value) -> Response {
882    json_response(
883        StatusCode::OK,
884        json!({ "jsonrpc": "2.0", "id": id, "result": result }),
885    )
886}
887
888fn json_rpc_success_with_session(id: Value, result: Value, session_id: &str) -> Response {
889    let mut response = json_rpc_success(id, result);
890    response.headers_mut().insert(
891        MCP_SESSION_ID_HEADER,
892        HeaderValue::from_str(session_id).expect("generated MCP session id is a valid header"),
893    );
894    response
895}
896
897fn json_rpc_error(id: Value, code: i64, message: impl Into<String>) -> Response {
898    json_response(
899        StatusCode::OK,
900        json!({
901            "jsonrpc": "2.0",
902            "id": id,
903            "error": {
904                "code": code,
905                "message": message.into()
906            }
907        }),
908    )
909}
910
911fn json_response(status: StatusCode, value: Value) -> Response {
912    (
913        status,
914        [(header::CONTENT_TYPE, "application/json")],
915        value.to_string(),
916    )
917        .into_response()
918}
919
920fn request_context(request_id: String) -> RequestContext {
921    RequestContext::with_ids(
922        InterfaceKind::Mcp,
923        format!("mcp-{request_id}"),
924        format!("trace-mcp-{request_id}"),
925    )
926}
927
928fn endpoint_child(endpoint: &str, child: &str) -> String {
929    if endpoint == "/" {
930        format!("/{child}")
931    } else {
932        format!("{}/{child}", endpoint.trim_end_matches('/'))
933    }
934}
935
936fn request_id_key(namespace: &str, value: &Value) -> Option<String> {
937    match value {
938        Value::String(value) => Some(format!("{namespace}|string:{value}")),
939        Value::Number(value) if value.is_i64() || value.is_u64() => {
940            Some(format!("{namespace}|number:{value}"))
941        }
942        _ => None,
943    }
944}
945
946fn is_json_rpc_id(value: &Value) -> bool {
947    match value {
948        Value::String(_) => true,
949        Value::Number(number) => number.is_i64() || number.is_u64(),
950        _ => false,
951    }
952}
953
954fn elapsed_millis(started: Instant) -> u64 {
955    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
956}
957
958fn qos_message(reason: RejectReason) -> &'static str {
959    match reason {
960        RejectReason::ConnectionBudgetExceeded => "connection budget exhausted",
961        RejectReason::RequestBudgetExceeded => "request budget exhausted",
962        RejectReason::QueueBudgetExceeded => "queue budget exhausted",
963    }
964}
965
966#[cfg(test)]
967#[path = "mcp_test_support.rs"]
968mod mcp_test_support;
969
970#[cfg(test)]
971#[path = "mcp_tests.rs"]
972mod mcp_tests;
973
974#[cfg(test)]
975#[path = "mcp_tool_tests.rs"]
976mod mcp_tool_tests;
977
978#[cfg(test)]
979#[path = "mcp_feature_flag_tool_tests.rs"]
980mod mcp_feature_flag_tool_tests;
981
982#[cfg(test)]
983#[path = "mcp_software_tool_tests.rs"]
984mod mcp_software_tool_tests;
985
986#[cfg(test)]
987#[path = "mcp_protocol_tests.rs"]
988mod mcp_protocol_tests;