use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::Value;
use crate::{
auth::check_auth_token,
handlers::AppState,
mcp,
service::{self, PublishRequest, UpdateRequest},
};
pub async fn handle_mcp_post(
State(state): State<AppState>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let resource_metadata_url = {
let base = state.config.base_url.trim_end_matches('/');
format!("{base}/.well-known/oauth-protected-resource")
};
let www_auth_value = format!("Bearer resource_metadata=\"{resource_metadata_url}\"");
let www_auth_header: axum::http::HeaderValue = www_auth_value
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("Bearer"));
let provided = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let token = match provided {
Some(t) => t.to_string(),
None => {
return (
StatusCode::UNAUTHORIZED,
[(
axum::http::header::WWW_AUTHENTICATE,
www_auth_header.clone(),
)],
axum::Json(serde_json::json!({
"error": "unauthorized",
"error_description": "Bearer token required"
})),
)
.into_response();
}
};
let principal = match check_auth_token(&state, &token).await {
Ok(p) => p,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, www_auth_header)],
axum::Json(serde_json::json!({
"error": "unauthorized",
"error_description": "Invalid or expired token"
})),
)
.into_response();
}
};
let request: mcp::Request = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => {
let resp = mcp::Response::err(Value::Null, -32700, format!("Parse error: {e}"));
return json_response(StatusCode::OK, &resp);
}
};
let id = match request.id.clone() {
Some(id) => id,
None => {
return StatusCode::ACCEPTED.into_response();
}
};
let resp = dispatch_tool_call(&state, principal, id, &request).await;
json_response(StatusCode::OK, &resp)
}
async fn dispatch_tool_call(
state: &AppState,
principal: crate::auth::Principal,
id: Value,
req: &mcp::Request,
) -> mcp::Response {
match req.method.as_str() {
"initialize" => handle_initialize(id),
"tools/list" => handle_tools_list(id),
"tools/call" => {
let params = match req.params.as_ref() {
Some(p) => p,
None => {
return mcp::Response::err(id, -32602, "Missing params".to_string());
}
};
let tool_name = match params.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => {
return mcp::Response::err(id, -32602, "Missing tool name".to_string());
}
};
let args = params
.get("arguments")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let result = call_tool(state, principal, tool_name, &args).await;
mcp::Response::ok(id, result)
}
_ => mcp::Response::err(id, -32601, format!("Method not found: {}", req.method)),
}
}
async fn call_tool(
state: &AppState,
principal: crate::auth::Principal,
tool_name: &str,
args: &Value,
) -> Value {
match tool_name {
"twofold_publish" => tool_publish(state, principal, args).await,
"twofold_get" => tool_get(state, args).await,
"twofold_list" => tool_list(state, args).await,
"twofold_delete" => tool_delete(state, principal, args).await,
"twofold_update" => tool_update(state, principal, args).await,
_ => tool_result_err(format!("Unknown tool: {tool_name}")),
}
}
async fn tool_publish(state: &AppState, principal: crate::auth::Principal, args: &Value) -> Value {
if !principal.can_write() {
return tool_result_err("Forbidden: token does not have write access".to_string());
}
let content = match args.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return tool_result_err("Missing required argument: content".to_string()),
};
let title = args.get("title").and_then(|v| v.as_str());
let slug = args.get("slug").and_then(|v| v.as_str());
let password = args.get("password").and_then(|v| v.as_str());
let expiry = args.get("expiry").and_then(|v| v.as_str());
let theme = args.get("theme").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
let agent_content = args.get("agent_content").and_then(|v| v.as_str());
let body = match build_publish_body(
content,
title,
slug,
password,
expiry,
theme,
description,
agent_content,
) {
Ok(b) => b,
Err(e) => return tool_result_err(e),
};
let req = PublishRequest {
raw_content: body,
principal,
client_ip: "mcp".to_string(),
};
match service::publish(&state.db, &state.config, req).await {
Ok(result) => {
let json = serde_json::json!({
"slug": result.slug,
"title": result.title,
"url": result.url,
"api_url": result.api_url,
"created_at": result.created_at,
"expires_at": result.expires_at,
});
let text = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
tool_result_ok(text)
}
Err(e) => tool_result_err(format!("{e:?}")),
}
}
async fn tool_update(state: &AppState, principal: crate::auth::Principal, args: &Value) -> Value {
if !principal.can_write() {
return tool_result_err("Forbidden: token does not have write access".to_string());
}
let slug = match args.get("slug").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_result_err("Missing required argument: slug".to_string()),
};
let content = match args.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return tool_result_err("Missing required argument: content".to_string()),
};
let title = args.get("title").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
let password = args.get("password").and_then(|v| v.as_str());
let expiry = args.get("expiry").and_then(|v| v.as_str());
let theme = args.get("theme").and_then(|v| v.as_str());
let agent_content = args.get("agent_content").and_then(|v| v.as_str());
let body = match build_publish_body(
content,
title,
None,
password,
expiry,
theme,
description,
agent_content,
) {
Ok(b) => b,
Err(e) => return tool_result_err(e),
};
let req = UpdateRequest {
raw_content: body,
principal,
client_ip: "mcp".to_string(),
};
match service::update(&state.db, &state.config, slug, req).await {
Ok(result) => {
let json = serde_json::json!({
"slug": result.slug,
"title": result.title,
"url": result.url,
"api_url": result.api_url,
"created_at": result.created_at,
"expires_at": result.expires_at,
});
let text = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
tool_result_ok(text)
}
Err(e) => match e {
crate::handlers::AppError::NotFound => {
tool_result_err(format!("Document not found: {slug}"))
}
crate::handlers::AppError::Gone => {
tool_result_err(format!("Document has expired: {slug}"))
}
_ => tool_result_err(format!("{e:?}")),
},
}
}
async fn tool_delete(state: &AppState, principal: crate::auth::Principal, args: &Value) -> Value {
if !principal.can_write() {
return tool_result_err("Forbidden: token does not have write access".to_string());
}
let slug = match args.get("slug").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_result_err("Missing required argument: slug".to_string()),
};
match service::delete(&state.db, &state.config, slug, &principal, "mcp").await {
Ok(()) => tool_result_ok(serde_json::json!({"success": true}).to_string()),
Err(crate::handlers::AppError::NotFound) => {
tool_result_err(format!("Document not found: {slug}"))
}
Err(e) => tool_result_err(format!("{e:?}")),
}
}
async fn tool_get(state: &AppState, args: &Value) -> Value {
let slug = match args.get("slug").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_result_err("Missing required argument: slug".to_string()),
};
match service::get(&state.db, slug).await {
Ok(doc) => {
let safe_content = crate::handlers::strip_password_from_content_pub(&doc.raw_content);
tool_result_ok(safe_content)
}
Err(crate::handlers::AppError::NotFound) => {
tool_result_err(format!("Document not found: {slug}"))
}
Err(crate::handlers::AppError::Gone) => {
tool_result_err(format!("Document not found: {slug}"))
}
Err(e) => tool_result_err(format!("{e:?}")),
}
}
async fn tool_list(state: &AppState, args: &Value) -> Value {
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as u32;
match service::list(&state.db, limit, 0).await {
Ok((documents, total)) => {
let json = serde_json::json!({
"documents": documents,
"total": total,
"limit": limit.min(100),
"offset": 0,
});
let text = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
tool_result_ok(text)
}
Err(e) => tool_result_err(format!("{e:?}")),
}
}
fn handle_initialize(id: Value) -> mcp::Response {
mcp::Response::ok(
id,
serde_json::json!({
"protocolVersion": "2024-11-05",
"serverInfo": {
"name": "twofold",
"version": env!("CARGO_PKG_VERSION"),
"icons": [
{
"src": "https://share.hearth.observer/icon.png",
"mime_type": "image/jpeg"
}
]
},
"capabilities": {
"tools": {}
}
}),
)
}
fn handle_tools_list(id: Value) -> mcp::Response {
mcp::tools_list_response(id)
}
#[allow(clippy::too_many_arguments)]
fn build_publish_body(
content: &str,
title: Option<&str>,
slug: Option<&str>,
password: Option<&str>,
expiry: Option<&str>,
theme: Option<&str>,
description: Option<&str>,
agent_content: Option<&str>,
) -> Result<String, String> {
let fields = crate::frontmatter::FrontmatterFields {
title: title.map(str::to_string),
slug: slug.map(str::to_string),
password: password.map(str::to_string),
expiry: expiry.map(str::to_string),
theme: theme.map(str::to_string),
description: description.map(str::to_string),
};
let mut body = crate::frontmatter::apply_frontmatter(content, fields);
if let Some(ac) = agent_content {
if crate::frontmatter::contains_marker_directive(ac) {
return Err(
"agent_content must not contain marker directives (<!-- @agent --> or <!-- @end -->)"
.to_string(),
);
}
body.push_str("\n\n<!-- @agent -->\n\n");
body.push_str(ac);
body.push_str("\n\n<!-- @end -->\n");
}
Ok(body)
}
fn tool_result_ok(text: String) -> Value {
serde_json::json!({
"content": [{ "type": "text", "text": text }]
})
}
fn tool_result_err(message: String) -> Value {
serde_json::json!({
"content": [{ "type": "text", "text": message }],
"isError": true
})
}
fn json_response(status: StatusCode, resp: &mcp::Response) -> Response {
match serde_json::to_vec(resp) {
Ok(body) => (
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to serialize MCP response");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}