//! `clawmates_skills` MCP server — exposes the skills catalog as //! MCP resources so agents can discover + read skill markdown on //! demand (Slice 3.5b of the missions consolidation). //! //! Delivery model: //! - Agents call `resources/list` and see one entry per skill //! visible to their workspace, with a URI like //! `skill:/`. The MCP `description` field //! carries the skill's `description + when_to_use` so the LLM //! can decide when to reach for it. //! - Agents call `resources/read` with that URI and get back the //! skill's body_md as `text/markdown`. //! - Pinning is honored via a companion tools/list entry //! `skills__pinned_bundle` that returns the concatenation of //! every pinned skill for the calling agent — a template role //! with pin_in_context=true, or an agent_skills_ext override //! with pin_in_context=true, participates. //! //! This is a separate JSON-RPC endpoint at `/mcp/skills` so the //! existing `/mcp` door stays unchanged. ZeroClaw's per-agent //! `[mcp.servers]` picks whichever endpoints the role's //! `mcp_bundles` config asks for. use axum::extract::State; use axum::http::header::AUTHORIZATION; use axum::http::HeaderMap; use axum::response::Json; use serde::Deserialize; use serde_json::json; use serde_json::Value; use uuid::Uuid; use crate::AppState; const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; const URI_PREFIX_GLOBAL: &str = "skill:global/"; const URI_PREFIX_WORKSPACE: &str = "skill:workspace/"; const PINNED_TOOL_NAME: &str = "skills__pinned_bundle"; // ── JSON-RPC types ─────────────────────────────────────────────── #[derive(Deserialize)] pub struct JsonRpcReq { #[allow(dead_code)] jsonrpc: Option, #[serde(default)] id: Option, method: String, #[serde(default)] params: Option, } fn ok(id: Option, result: Value) -> Json { Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })) } fn err(id: Option, code: i64, message: &str) -> Json { Json(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })) } // ── Auth ───────────────────────────────────────────────────────── /// This endpoint accepts a **narrow** credential as well as a person's session. /// /// It is the one route a mission container is given a token for, and that token /// sits in a file the agent can `cat`. Mission agents run arbitrary `Bash` with /// egress and no read gate, so a full session here would be an owner-privileged /// API key handed to something explicitly untrusted — which is why /// `SCOPE_SKILLS_READ` exists and why this is the only call site that names it. /// /// `authenticate_scoped` still accepts `full`, so the UI and any human caller /// are unaffected. async fn authed(state: &AppState, headers: &HeaderMap) -> Option { let token = headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer "))?; state .auth .authenticate_scoped(token, cm_auth::SCOPE_SKILLS_READ) .await .ok() } /// Resolve the calling agent via `X-ZeroClaw-Agent` header /// (`claw_`). Returns None when the header is missing or the /// agent doesn't belong to the caller's workspace — pinning falls /// back to "no pins" in that case. async fn caller_agent( state: &AppState, user: &cm_auth::AuthedUser, headers: &HeaderMap, ) -> Option { let alias = headers .get("x-zeroclaw-agent") .and_then(|v| v.to_str().ok())?; let hex = alias.strip_prefix("claw_")?; let uuid = Uuid::parse_str(hex).ok()?; let agent_id = cm_domain::AgentId::from(uuid); let agent = cm_db::repo::agents::get(&state.pool, agent_id).await.ok()?; if agent.workspace_id != user.workspace_id { return None; } Some(agent.id.as_uuid()) } // ── URI helpers ────────────────────────────────────────────────── fn skill_uri(workspace_id: Option, name: &str) -> String { match workspace_id { Some(ws) => format!("{URI_PREFIX_WORKSPACE}{ws}/{name}"), None => format!("{URI_PREFIX_GLOBAL}{name}"), } } /// Parse `skill:global/` or `skill:workspace//`. fn parse_uri(uri: &str) -> Option<(Option, String)> { if let Some(name) = uri.strip_prefix(URI_PREFIX_GLOBAL) { return Some((None, name.to_string())); } if let Some(rest) = uri.strip_prefix(URI_PREFIX_WORKSPACE) { let (ws_str, name) = rest.split_once('/')?; let ws = Uuid::parse_str(ws_str).ok()?; return Some((Some(ws), name.to_string())); } None } // ── Handler ────────────────────────────────────────────────────── pub async fn mcp_skills( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Json { match req.method.as_str() { "initialize" => ok( req.id, json!({ "protocolVersion": MCP_PROTOCOL_VERSION, // Advertise both resources (skills discovery + read) and // one tool (pinned bundle — a convenience for the pinned // set that would otherwise need N resources/read calls). "capabilities": { "resources": {}, "tools": {} }, "serverInfo": { "name": "clawmates_skills", "version": env!("CARGO_PKG_VERSION"), }, }), ), "notifications/initialized" => Json(json!({ "jsonrpc": "2.0" })), // ── Resources ──────────────────────────────────────────── "resources/list" => { let Some(user) = authed(&state, &headers).await else { return err(req.id, -32001, "unauthorized"); }; let skills = match cm_db::repo::skills_catalog::list_visible( &state.pool, user.workspace_id.as_uuid(), ) .await { Ok(s) => s, Err(e) => { return err(req.id, -32000, &format!("list_visible failed: {e}")); } }; let resources: Vec = skills .into_iter() .map(|s| { let uri = skill_uri(s.workspace_id, &s.name); let desc = match s.when_to_use.as_deref() { Some(w) if !w.is_empty() => { format!("{}\n\nWhen to use: {}", s.description, w) } _ => s.description.clone(), }; json!({ "uri": uri, "name": s.name, "description": desc, "mimeType": "text/markdown", }) }) .collect(); ok(req.id, json!({ "resources": resources })) } "resources/read" => { let Some(user) = authed(&state, &headers).await else { return err(req.id, -32001, "unauthorized"); }; let params = req.params.clone().unwrap_or_else(|| json!({})); let Some(uri) = params.get("uri").and_then(|v| v.as_str()) else { return err(req.id, -32602, "missing param `uri`"); }; let Some((ws, name)) = parse_uri(uri) else { return err(req.id, -32602, "unrecognized uri scheme"); }; // Enforce workspace scope: an agent can only read a // workspace-scoped skill from its own workspace. let cross_ws = ws .map(|w| w != user.workspace_id.as_uuid()) .unwrap_or(false); if cross_ws { return err(req.id, -32001, "cross-workspace read denied"); } let skill = match cm_db::repo::skills_catalog::get_by_name(&state.pool, ws, &name).await { Ok(Some(s)) => s, Ok(None) => return err(req.id, -32602, "skill not found"), Err(e) => { return err(req.id, -32000, &format!("get_by_name failed: {e}")); } }; ok( req.id, json!({ "contents": [{ "uri": uri, "mimeType": "text/markdown", "text": skill.body, }], }), ) } // ── Pinned tool ────────────────────────────────────────── // Convenience: one call returns every skill pinned for the // calling agent, concatenated with H1 headers. Cheaper than // N resources/read calls when the pin set is stable. "tools/list" => { if authed(&state, &headers).await.is_none() { return err(req.id, -32001, "unauthorized"); } let tool = json!({ "name": PINNED_TOOL_NAME, "description": "Return every skill pinned for the calling agent, concatenated \ with H1 headers. Call once at turn start when you have pinned \ skills; otherwise read individual skills via resources/read.", "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false, }, }); ok(req.id, json!({ "tools": [tool] })) } "tools/call" => { let Some(user) = authed(&state, &headers).await else { return err(req.id, -32001, "unauthorized"); }; let params = req.params.clone().unwrap_or_else(|| json!({})); let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name != PINNED_TOOL_NAME { return err(req.id, -32601, "unknown tool"); } let agent_id = match caller_agent(&state, &user, &headers).await { Some(id) => id, // Fall back to "no pins" when the calling agent can't be // identified — a shared-runtime agent, or a legacy agent // that pre-dates the alias header. Returning empty text // is preferable to an error the LLM has to reason about. None => { return ok( req.id, json!({ "isError": false, "content": [{ "type": "text", "text": "" }], }), ); } }; // Resolve template + role via agent_template_link so we // can merge template default skills with per-agent // overrides. Missing link (agent wasn't materialized from // a template) → overrides-only, which is fine. let link = cm_db::repo::agent_template_link::get(&state.pool, agent_id) .await .ok() .flatten(); let (tpl_id, slot) = link .as_ref() .map(|l| (Some(l.template_id), Some(l.role_slot.as_str()))) .unwrap_or((None, None)); let bindings = match cm_db::repo::skills_catalog::effective_for_agent( &state.pool, agent_id, tpl_id, slot, ) .await { Ok(b) => b, Err(e) => { return err(req.id, -32000, &format!("effective_for_agent failed: {e}")); } }; let mut out = String::new(); for b in bindings.iter().filter(|b| b.pin_in_context) { out.push_str("# "); out.push_str(&b.skill.name); out.push('\n'); out.push_str(&b.skill.body); out.push_str("\n\n"); } ok( req.id, json!({ "isError": false, "content": [{ "type": "text", "text": out }], }), ) } // Unknown method — reply with JSON-RPC method-not-found so // ZeroClaw's MCP client falls back cleanly. _ => err(req.id, -32601, "method not found"), } } #[cfg(test)] mod tests { use super::*; #[test] fn uri_roundtrip_global() { let uri = skill_uri(None, "workspace-repo-commit-protocol"); assert_eq!(uri, "skill:global/workspace-repo-commit-protocol"); let (ws, name) = parse_uri(&uri).unwrap(); assert!(ws.is_none()); assert_eq!(name, "workspace-repo-commit-protocol"); } #[test] fn uri_roundtrip_workspace() { let ws = Uuid::new_v4(); let uri = skill_uri(Some(ws), "team-conventions"); let (parsed_ws, name) = parse_uri(&uri).unwrap(); assert_eq!(parsed_ws, Some(ws)); assert_eq!(name, "team-conventions"); } #[test] fn parse_uri_rejects_unknown_scheme() { assert!(parse_uri("file:///etc/passwd").is_none()); assert!(parse_uri("skill:garbage").is_none()); } }