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
@@ -6,6 +6,7 @@ mod error;
|
|||||||
mod extract;
|
mod extract;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
|
mod mcp_skills;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
mod recursive_exec;
|
mod recursive_exec;
|
||||||
@@ -176,6 +177,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
get(routes::tailscale::placement_get).put(routes::tailscale::placement_set),
|
get(routes::tailscale::placement_get).put(routes::tailscale::placement_set),
|
||||||
)
|
)
|
||||||
.route("/mcp", post(mcp_door::mcp))
|
.route("/mcp", post(mcp_door::mcp))
|
||||||
|
.route("/mcp/skills", post(mcp_skills::mcp_skills))
|
||||||
.route("/api/auth/login", post(routes::auth::login))
|
.route("/api/auth/login", post(routes::auth::login))
|
||||||
.route("/api/auth/logout", post(routes::auth::logout))
|
.route("/api/auth/logout", post(routes::auth::logout))
|
||||||
.route("/api/user/me", get(routes::identity::me))
|
.route("/api/user/me", get(routes::identity::me))
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -642,7 +642,11 @@ fn prewrite_daemon_config_with_risk(
|
|||||||
if current_mcp_name.is_some() && trimmed.starts_with("name") {
|
if current_mcp_name.is_some() && trimmed.starts_with("name") {
|
||||||
if let Some(v) = trimmed.split('=').nth(1) {
|
if let Some(v) = trimmed.split('=').nth(1) {
|
||||||
let v = v.trim().trim_matches('"');
|
let v = v.trim().trim_matches('"');
|
||||||
if v == "clawmates" {
|
// Both the `clawmates` (door) and `clawmates_skills`
|
||||||
|
// (Slice 3.5b MCP resources server) point at cm-api,
|
||||||
|
// so their bearers get the same workspace-owner
|
||||||
|
// session token rewrite.
|
||||||
|
if v == "clawmates" || v == "clawmates_skills" {
|
||||||
in_mcp_clawmates = true;
|
in_mcp_clawmates = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user