relay-knowledge 1.1.5

Graph-database-based knowledge graph project.
Documentation
use std::time::{Duration, Instant};

use serde::Deserialize;
use serde_json::{Value, json};

use super::{
    McpMethodError, McpServer,
    audit_bridge::{McpMethodAudit, record_mcp_method_audit},
    elapsed_millis, request_context,
};
use crate::interfaces::agent::{AgentAdapterError, AgentAdapterErrorKind, AgentAuditStatus};

const SERVICE_STATUS_URI: &str = "relay://service/status";
const HEALTH_URI: &str = "relay://service/health";
const INDEX_STATUS_URI: &str = "relay://indexes/status";
const GRAPH_SUMMARY_URI: &str = "relay://graph/summary";
const METRICS_URI: &str = "relay://metrics/prometheus";

#[derive(Debug, Deserialize)]
struct ResourceReadParams {
    uri: String,
    #[serde(default)]
    source_scope: Option<String>,
}

pub(super) fn list_resources(server: &McpServer) -> Value {
    let graph_summary = server.agent.access_policy.allow_unspecified_scope.then(|| {
        resource_descriptor(
            GRAPH_SUMMARY_URI,
            "relay_graph_summary",
            "Graph aggregate counts",
            "application/json",
        )
    });
    let mut resources = vec![
        resource_descriptor(
            SERVICE_STATUS_URI,
            "relay_service_status",
            "Resident service status",
            "application/json",
        ),
        resource_descriptor(
            HEALTH_URI,
            "relay_health",
            "Graph and index health",
            "application/json",
        ),
        resource_descriptor(
            INDEX_STATUS_URI,
            "relay_index_status",
            "Derived retrieval index status",
            "application/json",
        ),
        resource_descriptor(
            METRICS_URI,
            "relay_metrics",
            "Prometheus text metrics",
            "text/plain",
        ),
    ];
    if let Some(graph_summary) = graph_summary {
        resources.push(graph_summary);
    }

    json!({ "resources": resources })
}

pub(super) async fn read_resource_with_timeout(
    server: &McpServer,
    params: Value,
    request_id: &str,
) -> Result<Value, McpMethodError> {
    let started = Instant::now();
    let timeout = Duration::from_millis(server.agent.access_policy.max_runtime_ms);
    match tokio::time::timeout(timeout, read_resource(server, params, request_id)).await {
        Ok(result) => result,
        Err(_) => {
            record_mcp_method_audit(
                server,
                McpMethodAudit {
                    operation: "resources/read",
                    request_id,
                    status: AgentAuditStatus::Failed,
                    source_scope: None,
                    result_count: None,
                    elapsed_ms: elapsed_millis(started),
                    error_kind: Some("timeout"),
                },
            )
            .await;
            Err(McpMethodError::timeout(
                "resources/read exceeded max_runtime_ms",
            ))
        }
    }
}

async fn read_resource(
    server: &McpServer,
    params: Value,
    request_id: &str,
) -> Result<Value, McpMethodError> {
    let started = Instant::now();
    let params = serde_json::from_value::<ResourceReadParams>(params).map_err(|error| {
        McpMethodError::invalid_params(format!("invalid resources/read params: {error}"))
    })?;
    let result = match params.uri.as_str() {
        SERVICE_STATUS_URI => service_status_content(server, request_id).await,
        HEALTH_URI => health_content(server, request_id).await,
        INDEX_STATUS_URI => index_status_content(server, request_id).await,
        GRAPH_SUMMARY_URI => {
            authorize_graph_summary(server, params.source_scope)?;
            graph_summary_content(server, request_id).await
        }
        METRICS_URI => metrics_content(server, request_id).await,
        _ => Err(McpMethodError::invalid_params("unknown resource uri")),
    };
    record_mcp_method_audit(
        server,
        McpMethodAudit {
            operation: "resources/read",
            request_id,
            status: if result.is_ok() {
                AgentAuditStatus::Completed
            } else {
                AgentAuditStatus::Failed
            },
            source_scope: None,
            result_count: None,
            elapsed_ms: elapsed_millis(started),
            error_kind: result.as_ref().err().map(|error| error.kind),
        },
    )
    .await;

    result
}

fn authorize_graph_summary(
    server: &McpServer,
    source_scope: Option<String>,
) -> Result<(), McpMethodError> {
    if !server.agent.access_policy.allow_unspecified_scope {
        return Err(McpMethodError::adapter(AgentAdapterError::new(
            AgentAdapterErrorKind::InvalidScope,
            "relay://graph/summary requires MCP allow_unspecified_scope=true",
        )));
    }
    if source_scope.is_some() {
        return Err(McpMethodError::invalid_params(
            "relay://graph/summary is graph-wide and does not accept source_scope",
        ));
    }

    Ok(())
}

async fn service_status_content(
    server: &McpServer,
    request_id: &str,
) -> Result<Value, McpMethodError> {
    let response = server
        .service
        .service_status(request_context(request_id.to_owned()))
        .await
        .map_err(McpMethodError::api)?;
    json_content(SERVICE_STATUS_URI, &response)
}

async fn health_content(server: &McpServer, request_id: &str) -> Result<Value, McpMethodError> {
    let response = server
        .service
        .health(request_context(request_id.to_owned()))
        .await
        .map_err(McpMethodError::api)?;
    json_content(HEALTH_URI, &response)
}

async fn index_status_content(
    server: &McpServer,
    request_id: &str,
) -> Result<Value, McpMethodError> {
    let response = server
        .service
        .health(request_context(request_id.to_owned()))
        .await
        .map_err(McpMethodError::api)?;
    json_content(
        INDEX_STATUS_URI,
        &json!({
            "metadata": response.metadata,
            "indexes": response.indexes,
            "index_cursors": response.index_cursors,
            "index_refresh": response.index_refresh
        }),
    )
}

async fn graph_summary_content(
    server: &McpServer,
    request_id: &str,
) -> Result<Value, McpMethodError> {
    let response = server
        .service
        .inspect_graph(
            crate::api::GraphInspectionRequest { source_scope: None },
            request_context(request_id.to_owned()),
        )
        .await
        .map_err(McpMethodError::api)?;
    json_content(GRAPH_SUMMARY_URI, &response)
}

async fn metrics_content(server: &McpServer, request_id: &str) -> Result<Value, McpMethodError> {
    let text = super::metrics::prometheus_metrics(server, request_id).await?;
    Ok(text_content(METRICS_URI, "text/plain", text))
}

fn resource_descriptor(uri: &str, name: &str, description: &str, mime_type: &str) -> Value {
    json!({
        "uri": uri,
        "name": name,
        "description": description,
        "mimeType": mime_type
    })
}

fn json_content<T: serde::Serialize>(uri: &str, value: &T) -> Result<Value, McpMethodError> {
    let text = serde_json::to_string(value)
        .map_err(|error| McpMethodError::internal(format!("failed to encode resource: {error}")))?;
    Ok(text_content(uri, "application/json", text))
}

fn text_content(uri: &str, mime_type: &str, text: String) -> Value {
    json!({
        "contents": [
            {
                "uri": uri,
                "mimeType": mime_type,
                "text": text
            }
        ]
    })
}