use crate::agent::Laser;
use crate::error::LaserError;
use crate::provenance::AgentTopic;
use crate::types::ConversationId;
#[cfg(feature = "mcp-http")]
use axum::Router;
#[cfg(feature = "mcp-http")]
use axum::extract::State;
#[cfg(feature = "mcp-http")]
use axum::routing::post;
use laser_wire::agent::{self as agdx, AgentEnvelope, AgentId, CorrelationId};
use laser_wire::content::ContentType;
use serde::{Deserialize, Serialize};
#[cfg(feature = "mcp-http")]
use serde_json::to_value;
use serde_json::{Value as JsonValue, json};
#[cfg(feature = "mcp-http")]
use std::str::FromStr;
#[cfg(feature = "mcp-http")]
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "mcp-http")]
use strum::{Display, EnumString};
#[cfg(feature = "mcp-http")]
const JSONRPC_VERSION: &str = "2.0";
#[cfg(feature = "mcp-http")]
const APP_ERROR_CODE: i32 = -32000;
const DEFAULT_PROTOCOL_VERSION: &str = "2025-11-25";
const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(feature = "mcp-http")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)]
pub enum McpMethod {
#[strum(serialize = "initialize")]
Initialize,
#[strum(serialize = "tools/list")]
ToolsList,
#[strum(serialize = "tools/call")]
ToolsCall,
#[strum(serialize = "resources/list")]
ResourcesList,
#[strum(serialize = "resources/read")]
ResourcesRead,
#[strum(serialize = "prompts/list")]
PromptsList,
#[strum(serialize = "prompts/get")]
PromptsGet,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpResource {
pub uri: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPromptArgument {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPrompt {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<McpPromptArgument>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "inputSchema")]
pub input_schema: JsonValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpContent {
#[serde(rename = "type")]
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpToolResult {
pub content: Vec<McpContent>,
#[serde(rename = "isError", default, skip_serializing_if = "is_false")]
pub is_error: bool,
}
pub fn tool_call_from_request(
record: agdx::RecordId,
conversation: agdx::ConversationId,
source: agdx::AgentId,
correlation: agdx::CorrelationId,
tool_name: impl Into<String>,
params_json: Vec<u8>,
) -> AgentEnvelope {
AgentEnvelope::command(record, conversation, source, correlation, params_json)
.with_tool(tool_name)
}
pub fn tool_result_from_envelope(envelope: &AgentEnvelope) -> McpToolResult {
let text = String::from_utf8_lossy(&envelope.body).into_owned();
let content = if text.is_empty() {
Vec::new()
} else {
vec![McpContent {
kind: "text".to_owned(),
text,
}]
};
McpToolResult {
content,
is_error: envelope.kind == agdx::AgentKind::Error,
}
}
fn is_false(value: &bool) -> bool {
!*value
}
pub struct McpBridge {
laser: Laser,
source: AgentId,
tool_topic: AgentTopic<'static>,
reply_topic: AgentTopic<'static>,
server_name: String,
tools: Vec<McpTool>,
resources: Vec<ResourceEntry>,
prompts: Vec<PromptEntry>,
timeout: Duration,
}
struct ResourceEntry {
resource: McpResource,
text: String,
}
struct PromptEntry {
prompt: McpPrompt,
messages: Vec<(String, String)>,
}
impl McpBridge {
pub fn new(
laser: Laser,
source: AgentId,
tool_topic: AgentTopic<'static>,
reply_topic: AgentTopic<'static>,
server_name: impl Into<String>,
) -> Self {
Self {
laser,
source,
tool_topic,
reply_topic,
server_name: server_name.into(),
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
timeout: DEFAULT_CALL_TIMEOUT,
}
}
pub fn with_resource(
mut self,
uri: impl Into<String>,
name: impl Into<String>,
mime_type: Option<String>,
text: impl Into<String>,
) -> Self {
self.resources.push(ResourceEntry {
resource: McpResource {
uri: uri.into(),
name: name.into(),
title: None,
description: None,
mime_type,
},
text: text.into(),
});
self
}
pub fn with_prompt(mut self, prompt: McpPrompt, messages: Vec<(String, String)>) -> Self {
self.prompts.push(PromptEntry { prompt, messages });
self
}
pub fn with_tool(
mut self,
name: impl Into<String>,
description: Option<String>,
input_schema: JsonValue,
) -> Self {
self.tools.push(McpTool {
name: name.into(),
title: None,
description,
input_schema,
});
self
}
pub fn with_memory_tools(self) -> Self {
self.with_tool(
"remember",
Some("Store a memory item for later recall.".to_owned()),
json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The content to remember." }
},
"required": ["text"]
}),
)
.with_tool(
"recall",
Some("Retrieve the memory items most relevant to a query.".to_owned()),
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "What to recall." },
"limit": { "type": "integer", "minimum": 1, "description": "Max items to return." }
},
"required": ["query"]
}),
)
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn initialize(&self, protocol_version: Option<&str>) -> JsonValue {
let mut capabilities = serde_json::Map::new();
capabilities.insert("tools".to_owned(), json!({}));
if !self.resources.is_empty() {
capabilities.insert("resources".to_owned(), json!({}));
}
if !self.prompts.is_empty() {
capabilities.insert("prompts".to_owned(), json!({}));
}
json!({
"protocolVersion": protocol_version.unwrap_or(DEFAULT_PROTOCOL_VERSION),
"serverInfo": { "name": self.server_name, "version": env!("CARGO_PKG_VERSION") },
"capabilities": capabilities,
})
}
pub fn list_tools(&self) -> JsonValue {
json!({ "tools": self.tools })
}
pub fn list_resources(&self) -> JsonValue {
let resources: Vec<&McpResource> = self.resources.iter().map(|e| &e.resource).collect();
json!({ "resources": resources })
}
pub fn read_resource(&self, uri: &str) -> Result<JsonValue, LaserError> {
let entry = self
.resources
.iter()
.find(|entry| entry.resource.uri == uri)
.ok_or_else(|| LaserError::Invalid(format!("unknown resource `{uri}`")))?;
let mut content = serde_json::Map::new();
content.insert("uri".to_owned(), json!(entry.resource.uri));
if let Some(mime_type) = &entry.resource.mime_type {
content.insert("mimeType".to_owned(), json!(mime_type));
}
content.insert("text".to_owned(), json!(entry.text));
Ok(json!({ "contents": [content] }))
}
pub fn list_prompts(&self) -> JsonValue {
let prompts: Vec<&McpPrompt> = self.prompts.iter().map(|e| &e.prompt).collect();
json!({ "prompts": prompts })
}
pub fn get_prompt(&self, name: &str) -> Result<JsonValue, LaserError> {
let entry = self
.prompts
.iter()
.find(|entry| entry.prompt.name == name)
.ok_or_else(|| LaserError::Invalid(format!("unknown prompt `{name}`")))?;
let messages: Vec<JsonValue> = entry
.messages
.iter()
.map(
|(role, text)| json!({ "role": role, "content": { "type": "text", "text": text } }),
)
.collect();
Ok(json!({ "description": entry.prompt.description, "messages": messages }))
}
pub async fn call_tool(
&self,
name: &str,
params_json: Vec<u8>,
) -> Result<McpToolResult, LaserError> {
let conversation = ConversationId::new();
let correlation = CorrelationId::from_u128(conversation.as_u128());
let mut reader = self
.laser
.agdx_reply_reader(self.reply_topic.clone())
.await?;
self.laser
.agdx(
self.tool_topic.clone(),
self.source.clone(),
conversation.into(),
)
.command(correlation, params_json)
.with_tool(name.to_owned())
.content_type(ContentType::Json)
.send()
.await?;
let envelope = self
.laser
.await_agdx_reply(&mut reader, correlation, self.timeout)
.await?;
Ok(tool_result_from_envelope(&envelope))
}
#[cfg(feature = "mcp-http")]
pub fn router(self: Arc<Self>) -> Router {
Router::new().route("/", post(handle_mcp)).with_state(self)
}
}
#[derive(Debug, Deserialize)]
pub struct McpRpcRequest {
pub id: JsonValue,
pub method: String,
#[serde(default)]
pub params: JsonValue,
}
#[derive(Debug, Serialize)]
pub struct McpRpcResponse {
pub jsonrpc: &'static str,
pub id: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<McpRpcError>,
}
#[derive(Debug, Serialize)]
pub struct McpRpcError {
pub code: i32,
pub message: String,
}
#[cfg(feature = "mcp-http")]
async fn handle_mcp(
State(bridge): State<Arc<McpBridge>>,
axum::Json(request): axum::Json<McpRpcRequest>,
) -> axum::Json<McpRpcResponse> {
let outcome = match McpMethod::from_str(&request.method) {
Ok(McpMethod::Initialize) => Ok(bridge.initialize(
request
.params
.get("protocolVersion")
.and_then(JsonValue::as_str),
)),
Ok(McpMethod::ToolsList) => Ok(bridge.list_tools()),
Ok(McpMethod::ResourcesList) => Ok(bridge.list_resources()),
Ok(McpMethod::ResourcesRead) => {
let uri = request
.params
.get("uri")
.and_then(JsonValue::as_str)
.unwrap_or_default();
bridge.read_resource(uri)
}
Ok(McpMethod::PromptsList) => Ok(bridge.list_prompts()),
Ok(McpMethod::PromptsGet) => {
let name = request
.params
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or_default();
bridge.get_prompt(name)
}
Ok(McpMethod::ToolsCall) => {
let name = request
.params
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_owned();
match serde_json::to_vec(&request.params) {
Ok(params_json) => match bridge.call_tool(&name, params_json).await {
Ok(result) => {
to_value(result).map_err(|error| LaserError::Codec(error.to_string()))
}
Err(error) => Err(error),
},
Err(error) => Err(LaserError::Codec(format!(
"tools/call params are not serializable: {error}"
))),
}
}
Err(_) => Err(LaserError::HandlerConfig(format!(
"unknown MCP method `{}`",
request.method
))),
};
let response = match outcome {
Ok(result) => McpRpcResponse {
jsonrpc: JSONRPC_VERSION,
id: request.id,
result: Some(result),
error: None,
},
Err(error) => {
tracing::warn!(error = %error, method = %request.method, "MCP request failed");
McpRpcResponse {
jsonrpc: JSONRPC_VERSION,
id: request.id,
result: None,
error: Some(McpRpcError {
code: APP_ERROR_CODE,
message: crate::error::public_error_message(&error).to_owned(),
}),
}
}
};
axum::Json(response)
}
#[cfg(test)]
mod tests {
use super::*;
use laser_wire::framing::{decode_named, encode_named};
use serde_json::to_string;
#[cfg(feature = "mcp-http")]
#[test]
fn given_mcp_method_when_round_tripped_then_should_match_the_wire_name() {
assert_eq!(McpMethod::ToolsCall.to_string(), "tools/call");
assert_eq!(
"tools/call".parse::<McpMethod>().expect("method parses"),
McpMethod::ToolsCall
);
assert_eq!(
"initialize".parse::<McpMethod>().expect("method parses"),
McpMethod::Initialize
);
assert_eq!(
"tools/list".parse::<McpMethod>().expect("method parses"),
McpMethod::ToolsList
);
assert_eq!(
"resources/read"
.parse::<McpMethod>()
.expect("method parses"),
McpMethod::ResourcesRead
);
assert_eq!(
"prompts/get".parse::<McpMethod>().expect("method parses"),
McpMethod::PromptsGet
);
assert!("completion/complete".parse::<McpMethod>().is_err());
}
#[test]
fn given_a_tool_call_when_mapped_then_params_should_tunnel_byte_identical() {
let params = br#"{"name":"search","arguments":{"q":"laser"},"_meta":{"trace":"abc"}}"#;
let envelope = tool_call_from_request(
agdx::RecordId::from_u128(1),
agdx::ConversationId::from_u128(2),
"source-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
"search",
params.to_vec(),
);
agdx::validate(&envelope).expect("mapped tool call validates");
let payload = encode_named(&envelope).expect("encodes");
let back: AgentEnvelope = decode_named(&payload).expect("decodes");
assert_eq!(back.tool.as_deref(), Some("search"));
assert_eq!(back.body, params.to_vec());
let response = AgentEnvelope::response(
agdx::RecordId::from_u128(5),
agdx::ConversationId::from_u128(2),
"source-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
b"found".to_vec(),
);
let result = tool_result_from_envelope(&response);
assert_eq!(
to_string(&result).expect("result serializes"),
r#"{"content":[{"type":"text","text":"found"}]}"#
);
let error = AgentEnvelope::error(
agdx::RecordId::from_u128(6),
agdx::ConversationId::from_u128(2),
"source-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
b"failed".to_vec(),
);
assert!(tool_result_from_envelope(&error).is_error);
}
}