use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::{Json, response::Response};
use serde_json::{Value, json};
use tracing::warn;
use super::ServeState;
use crate::provider::ChatMessage;
use crate::session::ReportMeta;
const PROTOCOL_VERSION: &str = "2025-03-26";
mod codes {
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const AUTH_REQUIRED: i32 = -32001;
}
#[derive(Debug, serde::Deserialize)]
struct JsonRpcEnvelope {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Option<Value>,
}
pub async fn handle_mcp_post(
State(state): State<Arc<ServeState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let envelope: JsonRpcEnvelope = match serde_json::from_slice(&body) {
Ok(env) => env,
Err(e) => {
return jsonrpc_error(
Value::Null,
codes::PARSE_ERROR,
&format!("invalid JSON: {e}"),
);
}
};
let req_id = envelope.id.clone().unwrap_or(Value::Null);
if envelope.jsonrpc.as_deref() != Some("2.0") {
return jsonrpc_error(
req_id,
codes::INVALID_REQUEST,
"jsonrpc field must be \"2.0\"",
);
}
if envelope.id.is_none() {
return (StatusCode::ACCEPTED, "").into_response();
}
let bearer = match extract_bearer(&headers) {
Some(b) => b,
None => return (StatusCode::UNAUTHORIZED, "missing bearer token").into_response(),
};
let profile_name = match state.config.resolve_a2a_token(&bearer) {
Some(name) => name.to_string(),
None => {
return jsonrpc_error(
req_id,
codes::AUTH_REQUIRED,
"unknown or revoked bearer token",
);
}
};
match envelope.method.as_str() {
"initialize" => handle_initialize(req_id),
"tools/list" => handle_tools_list(req_id),
"tools/call" => handle_tools_call(state, req_id, envelope.params, profile_name).await,
other => jsonrpc_error(
req_id,
codes::METHOD_NOT_FOUND,
&format!("MCP method '{other}' is not implemented"),
),
}
}
fn handle_initialize(req_id: Value) -> Response {
let result = json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"tools": { "listChanged": false }
},
"serverInfo": {
"name": "sapphire-agent",
"version": env!("CARGO_PKG_VERSION"),
}
});
jsonrpc_result(req_id, result)
}
fn handle_tools_list(req_id: Value) -> Response {
let tools = json!([
{
"name": "write_report",
"description":
"Report a unit of work back to sapphire-agent. The agent files it under \
the named project's memory and replies with a brief acknowledgement \
that can be shown to the user. Call this when a user-visible task is \
complete or at the end of a coding session.",
"inputSchema": {
"type": "object",
"properties": {
"project": {
"type": "string",
"description":
"Logical project key (typically the repository name). \
Stable across hosts and tools — re-using the same value \
continues the same project's session."
},
"summary": {
"type": "string",
"description": "One-line summary of what was just done."
},
"body": {
"type": "string",
"description": "Optional longer description, decisions, follow-ups."
},
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of file paths touched by this work."
},
"source": {
"type": "string",
"description":
"Identifier for the calling tool. Defaults to \"claude-code\" \
when omitted."
},
"hostname": {
"type": "string",
"description":
"Originating host. Recommended when the same project may be \
worked on from multiple machines."
}
},
"required": ["project", "summary"]
}
},
{
"name": "recall_memory",
"description":
"Recall prior context for a project from sapphire-agent. Returns the \
project's compacted summary plus the most recent reports. Call this \
at the start of a session to pick up where work was left off, \
possibly on another host.",
"inputSchema": {
"type": "object",
"properties": {
"project": {
"type": "string",
"description":
"Project key to recall. Should match what `write_report` \
was called with for the same project."
},
"limit": {
"type": "integer",
"description":
"Maximum number of recent reports to return verbatim. \
Older content is reflected in the project_summary. \
Defaults to 20.",
"minimum": 1
}
},
"required": ["project"]
}
}
]);
jsonrpc_result(req_id, json!({ "tools": tools }))
}
async fn handle_tools_call(
state: Arc<ServeState>,
req_id: Value,
params: Option<Value>,
profile_name: String,
) -> Response {
let params = params.unwrap_or(Value::Null);
let name = match params.get("name").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => {
return jsonrpc_error(
req_id,
codes::INVALID_PARAMS,
"tools/call requires a `name` field",
);
}
};
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
match name.as_str() {
"write_report" => {
let result = match call_write_report(state, profile_name, args).await {
Ok(v) => v,
Err(e) => tool_text_error(&format!("write_report failed: {e}")),
};
jsonrpc_result(req_id, result)
}
"recall_memory" => {
let result = match call_recall_memory(state, profile_name, args).await {
Ok(v) => v,
Err(e) => tool_text_error(&format!("recall_memory failed: {e}")),
};
jsonrpc_result(req_id, result)
}
other => jsonrpc_result(req_id, tool_text_error(&format!("unknown tool '{other}'"))),
}
}
const DEFAULT_SOURCE: &str = "claude-code";
const ACK_SYSTEM_PROMPT: &str = "You are sapphire-agent, the user's personal partner AI. Their external AI \
assistant (such as Claude Code) is reporting a unit of coding work it just \
completed on the user's behalf. Acknowledge the report warmly and briefly \
(1-3 sentences). Speak naturally — your reply is shown both to the assistant \
and to the user. Reply in the same language as the report's content.";
async fn call_write_report(
state: Arc<ServeState>,
profile_name: String,
args: Value,
) -> anyhow::Result<Value> {
let project = require_string(&args, "project")?;
let summary = require_string(&args, "summary")?;
let body = optional_string(&args, "body");
let files = optional_string_array(&args, "files");
let source = optional_string(&args, "source").unwrap_or_else(|| DEFAULT_SOURCE.to_string());
let hostname = optional_string(&args, "hostname");
let namespace = state
.config
.namespace_for_room_profile(&profile_name)
.to_string();
let session_id = state
.mcp_session_for_project_or_create(&namespace, &project)
.await?;
let report_text = render_report(
&summary,
body.as_deref(),
files.as_deref(),
&source,
hostname.as_deref(),
);
let report_meta = ReportMeta {
source: source.clone(),
hostname: hostname.clone(),
summary: summary.clone(),
body: body.clone(),
files: files.clone(),
};
state
.mcp_session_store
.append_report(&session_id, &report_text, report_meta)?;
let provider = state.registry.for_profile(&state.config, &profile_name);
let chat_response = provider
.chat(
Some(ACK_SYSTEM_PROMPT),
&[ChatMessage::user(report_text.clone())],
None,
)
.await;
let ack_text = match chat_response {
Ok(resp) => resp
.text
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| default_ack(&project)),
Err(e) => {
warn!("MCP write_report: ack LLM call failed: {e}");
default_ack(&project)
}
};
if let Err(e) = state
.mcp_session_store
.append(&session_id, &ChatMessage::assistant(ack_text.clone()))
{
warn!("MCP write_report: failed to persist ack to session {session_id}: {e}");
}
Ok(tool_text_ok(&ack_text))
}
fn render_report(
summary: &str,
body: Option<&str>,
files: Option<&[String]>,
source: &str,
hostname: Option<&str>,
) -> String {
let header = match hostname {
Some(h) => format!("[Report from {source} on {h}]"),
None => format!("[Report from {source}]"),
};
let mut out = format!("{header}\nSummary: {summary}");
if let Some(b) = body
&& !b.trim().is_empty()
{
out.push_str("\n\nDetails:\n");
out.push_str(b);
}
if let Some(fs) = files
&& !fs.is_empty()
{
out.push_str("\n\nFiles touched:");
for f in fs {
out.push_str(&format!("\n- {f}"));
}
}
out
}
fn default_ack(project: &str) -> String {
format!("ありがとう、{project} の作業お疲れさま。記録しておいたよ。")
}
const RECALL_LIMIT_MAX: usize = 100;
const RECALL_LIMIT_DEFAULT: usize = 20;
async fn call_recall_memory(
state: Arc<ServeState>,
profile_name: String,
args: Value,
) -> anyhow::Result<Value> {
let project = require_string(&args, "project")?;
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(RECALL_LIMIT_DEFAULT)
.clamp(1, RECALL_LIMIT_MAX);
let namespace = state
.config
.namespace_for_room_profile(&profile_name)
.to_string();
let session_id = state.mcp_session_for_project(&namespace, &project).await;
let Some(session_id) = session_id else {
return Ok(tool_text_ok(&render_empty_briefing(&project)));
};
let (messages, summary_line) = state
.mcp_session_store
.load_session_full(&session_id)
.ok_or_else(|| {
anyhow::anyhow!(
"project '{project}' is indexed but session file {session_id} is missing"
)
})?;
let project_summary = summary_line.map(|s| s.summary).unwrap_or_default();
let reports: Vec<_> = messages
.into_iter()
.filter(|m| m.report_meta.is_some())
.collect();
let recent: Vec<_> = reports
.into_iter()
.rev()
.take(limit)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
Ok(tool_text_ok(&render_briefing(
&project,
&project_summary,
&recent,
)))
}
fn render_empty_briefing(project: &str) -> String {
format!(
"# Recall for project: {project}\n\n\
No prior reports have been filed for this project yet. \
Use `write_report` to file the first one when work is complete."
)
}
fn render_briefing(
project: &str,
project_summary: &str,
reports: &[crate::session::StoredMessage],
) -> String {
let mut out = format!("# Recall for project: {project}\n");
if !project_summary.trim().is_empty() {
out.push_str("\n## Project summary (older history, compacted)\n\n");
out.push_str(project_summary);
out.push('\n');
}
if reports.is_empty() {
out.push_str(
"\n## Recent reports\n\n(no recent reports — the project summary above is the full record)\n",
);
return out;
}
out.push_str(&format!(
"\n## Recent reports ({} entries)\n",
reports.len()
));
for (idx, msg) in reports.iter().enumerate() {
let Some(meta) = &msg.report_meta else {
continue;
};
let ts = msg.timestamp.format("%Y-%m-%d %H:%M UTC");
let origin = match &meta.hostname {
Some(h) => format!("{} on {}", meta.source, h),
None => meta.source.clone(),
};
out.push_str(&format!("\n### Report {} — {ts} ({origin})\n", idx + 1));
out.push_str(&format!("Summary: {}\n", meta.summary));
if let Some(body) = &meta.body
&& !body.trim().is_empty()
{
out.push_str("\nDetails:\n");
out.push_str(body);
out.push('\n');
}
if let Some(files) = &meta.files
&& !files.is_empty()
{
out.push_str("\nFiles:\n");
for f in files {
out.push_str(&format!("- {f}\n"));
}
}
}
out
}
fn require_string(args: &Value, field: &str) -> anyhow::Result<String> {
args.get(field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required string argument '{field}'"))
}
fn optional_string(args: &Value, field: &str) -> Option<String> {
args.get(field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn optional_string_array(args: &Value, field: &str) -> Option<Vec<String>> {
args.get(field).and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
}
fn extract_bearer(headers: &HeaderMap) -> Option<String> {
let value = headers.get(axum::http::header::AUTHORIZATION)?;
let s = value.to_str().ok()?;
let token = s
.strip_prefix("Bearer ")
.or_else(|| s.strip_prefix("bearer "))?;
let trimmed = token.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn jsonrpc_result(id: Value, result: Value) -> Response {
let body = json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
});
(StatusCode::OK, Json(body)).into_response()
}
fn jsonrpc_error(id: Value, code: i32, message: &str) -> Response {
let body = json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message },
});
(StatusCode::OK, Json(body)).into_response()
}
fn tool_text_error(message: &str) -> Value {
json!({
"content": [{
"type": "text",
"text": message,
}],
"isError": true,
})
}
fn tool_text_ok(text: &str) -> Value {
json!({
"content": [{
"type": "text",
"text": text,
}],
"isError": false,
})
}