slice 3.5b: clawmates_skills MCP server
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Exposes the skills catalog as MCP resources so agents can discover
+ read skill markdown on demand — the delivery half of the "skills
teach how to think, MCP gives ability to act" model.

New endpoint: POST /mcp/skills (parallel to the existing /mcp door).
Protocols supported:
  - initialize            — handshake, advertises resources + tools
  - resources/list        — one entry per skill visible to the caller's
                            workspace. URI scheme:
                              skill:global/<name>
                              skill:workspace/<ws>/<name>
                            description carries the skill's description
                            + when_to_use so the LLM can decide when
                            to reach for it.
  - resources/read {uri}  — returns body as text/markdown. Enforces
                            workspace scope on workspace-authored
                            skills.
  - tools/list            — one entry: skills__pinned_bundle
  - tools/call             — skills__pinned_bundle concatenates every
                            skill pinned for the calling agent (via
                            X-ZeroClaw-Agent header) with H1 headers.
                            Cheaper than N resources/read at turn start.

Adds the `clawmates_skills` bundle to the runtime template config
alongside `clawmates_door`, and teaches the MCP bearer rewriter
(prewrite_daemon_config_with_risk) to inject the workspace-owner
service session bearer on both `clawmates` and `clawmates_skills`
servers.

Follow-ups:
  - Slice 3.5c: seed ~40-60 builtin skills across the 6 stacks
  - Slice 3.5d: agent_template_link lineage — until it lands, the
    pinned_bundle tool can only surface per-agent overrides, not
    template defaults (documented inline)

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 13:20:56 -07:00
co-authored by Claude Opus 4.7
parent 5f988ce022
commit 3524579c9a
3 changed files with 350 additions and 1 deletions
+343
View File
@@ -0,0 +1,343 @@
//! `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:<workspace_scope>/<name>`. 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::{HeaderMap, header::AUTHORIZATION};
use axum::response::Json;
use serde::Deserialize;
use serde_json::{Value, json};
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<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Option<Value>,
}
fn ok(id: Option<Value>, result: Value) -> Json<Value> {
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}
fn err(id: Option<Value>, code: i64, message: &str) -> Json<Value> {
Json(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }))
}
// ── Auth ─────────────────────────────────────────────────────────
async fn authed(state: &AppState, headers: &HeaderMap) -> Option<cm_auth::AuthedUser> {
let token = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))?;
state.auth.authenticate(token).await.ok()
}
/// Resolve the calling agent via `X-ZeroClaw-Agent` header
/// (`claw_<hex>`). 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<Uuid> {
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<Uuid>, name: &str) -> String {
match workspace_id {
Some(ws) => format!("{URI_PREFIX_WORKSPACE}{ws}/{name}"),
None => format!("{URI_PREFIX_GLOBAL}{name}"),
}
}
/// Parse `skill:global/<name>` or `skill:workspace/<ws>/<name>`.
fn parse_uri(uri: &str) -> Option<(Option<Uuid>, 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<AppState>,
headers: HeaderMap,
Json(req): Json<JsonRpcReq>,
) -> Json<Value> {
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<Value> = 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.
if let Some(ws) = ws {
if ws != user.workspace_id.as_uuid() {
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": "" }],
}),
);
}
};
// template + role for this agent — Slice 3.5d wires
// agent_template_link; until then we can't merge template
// defaults and only surface agent_skills_ext overrides.
let bindings = match cm_db::repo::skills_catalog::effective_for_agent(
&state.pool,
agent_id,
None,
None,
)
.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());
}
}