slice 3.5b: clawmates_skills MCP server
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:
co-authored by
Claude Opus 4.7
parent
5f988ce022
commit
3524579c9a
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user