Files
clawmates/crates/cm-api/src/mcp_door.rs
T
Omar SobhandClaude Opus 5 76ac3714f1
deploy / test (push) Successful in 5m15s
deploy / build (push) Successful in 5m28s
sec(door): closed by default, governor fails closed; self-authoring off by default
Three fail-open paths on the §15 door: no env at all meant allow-all; a
governor that could not be reached approved with a WARNING; and a reply
that never said DENY — empty, truncated, a refusal — approved, because the
rule was !contains("DENY"). On the two days the judge plan emptied every
outbound action was approved by nobody.

Now: governor_allows() needs an explicit ALLOW and no DENY; both judge()
implementations return false when unreachable; with no governor the door
opens only on CLAWMATES_DOOR_POLICY=allow. Open Agent Passport (arXiv
2603.20953): 74.6% social-engineering success under a permissive policy,
0 of 879 under a restrictive one. Local override gains the governor prod
already runs.

skill_self_authoring: default flipped to OFF. No agent-authored skill has
ever been delivered to a mission or scored; prod held zero proposals.
Enable with CLAWMATES_SKILL_SELF_AUTHORING=1 once promoted skills go
through the files arm and get a Skill-Use score.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-20 22:07:39 -05:00

676 lines
27 KiB
Rust

//! The Clawmates §15 MCP "door": the single gated egress a tool-free ZeroClaw
//! agent uses to act on the world.
//!
//! Agents have **no native outbound tools**; their only actuator is this MCP
//! server (`POST /mcp`, JSON-RPC 2.0, protocol `2024-11-05`). The door keeps the
//! parts that make capability *governed* — the agent never holds a credential
//! (broker-executed tools reveal secrets only inside the broker), every action
//! is journaled to the append-only audit log, and a central policy decides each
//! call — but the **human approver is replaced by an automated policy/governor**
//! ("agents control their destiny"). Recipient allowlists, spend caps, taint
//! blocks, or a governor agent plug into [`policy_decide`]. Since 2026-09-20
//! the door is **closed by default**: a call is approved only by a governor
//! that answered ALLOW, or by an explicit `CLAWMATES_DOOR_POLICY=allow`.
//!
//! v1 exposes `email_send` (runtime-executed → `outbox`, observable, no external
//! creds). Broker-backed tools (e.g. `slack_post`) are the next increment — they
//! additionally need the approval→grant chain the broker consumes.
use axum::extract::State;
use axum::http::header::AUTHORIZATION;
use axum::http::HeaderMap;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::AppState;
/// MCP protocol version the ZeroClaw client negotiates.
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Tools the door exposes, as `(mcp_name, internal_registry_name)`. The agent
/// sees `clawmates__<mcp_name>`; ZeroClaw strips the prefix and calls us with
/// `<mcp_name>`. We keep MCP names underscore-only (some models choke on dots).
const EXPOSED_TOOLS: &[(&str, &str)] = &[
("email_send", "email.send"),
("slack_post", "slack.post"),
("delegate", "delegate"),
];
fn internal_name(mcp_name: &str) -> Option<&'static str> {
EXPOSED_TOOLS
.iter()
.find(|(m, _)| *m == mcp_name)
.map(|(_, i)| *i)
}
/// A JSON-RPC 2.0 request frame.
#[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 } }))
}
/// A tool-call result in MCP shape (the agent reads `content`; `isError`
/// distinguishes a refused/failed call from a successful one).
fn tool_result(id: Option<Value>, is_error: bool, text: String) -> Json<Value> {
ok(
id,
json!({ "isError": is_error, "content": [{ "type": "text", "text": text }] }),
)
}
/// Door policy decision. The human is removed: this is where autonomy is
/// governed (or not).
enum PolicyOutcome {
Approve,
Deny(String),
}
/// Decides each door call in place of a human. The human is removed; autonomy
/// is *governed* (not ungoverned) by, in order:
/// 1. a kill switch (`CLAWMATES_DOOR_POLICY=deny`) — halt all egress, no redeploy;
/// 2. a per-workspace hourly rate cap (`CLAWMATES_DOOR_RATE_LIMIT`, counts
/// executed door actions in the audit log);
/// 3. an email recipient-domain allowlist (`CLAWMATES_DOOR_EMAIL_ALLOW`);
/// 4. a governor agent (`CLAWMATES_DOOR_GOVERNOR`) — must answer ALLOW;
/// unreachable, silent, or off-contract means DENY;
/// 5. with no governor, an explicit `CLAWMATES_DOOR_POLICY=allow`.
///
/// Default (no env set) = **deny**. This was allow-all until 2026-09-20, and
/// the governor failed open on top of that, so with the judge plan emptied
/// every outbound action was approved by nobody. See [`ungoverned_default`]
/// and `cm_runtime::Runtime::judge` for the measurement behind the flip.
async fn policy_decide(
state: &AppState,
workspace: cm_domain::WorkspaceId,
mcp_tool: &str,
category: Option<cm_domain::GatedCategory>,
args: &Value,
) -> PolicyOutcome {
// 1. Kill switch.
if std::env::var("CLAWMATES_DOOR_POLICY").as_deref() == Ok("deny") {
return PolicyOutcome::Deny("door policy is set to deny (kill switch active)".into());
}
// 2. Per-workspace hourly rate cap (counts executed door actions).
if let Some(cap) = std::env::var("CLAWMATES_DOOR_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<i64>().ok())
{
let used: i64 = sqlx::query_scalar(
"SELECT count(*) FROM audit_log
WHERE workspace_id = $1 AND event_type = 'door.executed'
AND created_at > now() - interval '1 hour'",
)
.bind(workspace.as_uuid())
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if used >= cap {
return PolicyOutcome::Deny(format!(
"hourly rate limit reached ({used}/{cap} door actions this hour)"
));
}
}
// 3. Email recipient-domain allowlist.
if mcp_tool == "email_send" {
if let Ok(allow) = std::env::var("CLAWMATES_DOOR_EMAIL_ALLOW") {
let to = args.get("to").and_then(|v| v.as_str()).unwrap_or("");
let domain = to.rsplit('@').next().unwrap_or("").to_ascii_lowercase();
let permitted = allow
.split(',')
.map(|d| d.trim().to_ascii_lowercase())
.any(|d| !d.is_empty() && d == domain);
if !permitted {
return PolicyOutcome::Deny(format!(
"recipient domain {domain:?} is not in CLAWMATES_DOOR_EMAIL_ALLOW"
));
}
}
}
// 4. Governor agent: when CLAWMATES_DOOR_GOVERNOR is set, an LLM judges the
// action — the "self-governing topology" path. Fail-CLOSED: a governor
// that cannot be reached, or that does not say ALLOW, denies. The
// deterministic rules above are the hard floor; this is the only
// approver.
if std::env::var("CLAWMATES_DOOR_GOVERNOR").is_ok() {
let system = "You are a security governor for an autonomous agent's outbound actions. \
Reply with exactly ALLOW or DENY on the first line, then one short reason. \
DENY if the action looks like data exfiltration, spam, credential/secret leakage, \
or sending sensitive or internal data to an untrusted external recipient. \
Otherwise ALLOW.";
let request = format!(
"Tool: {mcp_tool}\nCategory: {}\nPayload: {}",
category.map(|c| c.as_str()).unwrap_or("none"),
serde_json::to_string(args).unwrap_or_default()
);
// `CLAWMATES_JUDGE_MODEL=runtime:<alias>` routes the governor through a
// ZeroClaw runtime agent (e.g. Kimi via kimi_cli on the subscription) —
// no platform API key needed. Otherwise the server-side registry judge.
let judge_model = cm_runtime::judge_model();
let (allow, reason) = if let Some(alias) = judge_model.strip_prefix("runtime:") {
match crate::topology_exec::ZeroClawDriveExecutor::from_env() {
Ok(exec) => exec.judge(alias.trim(), system, &request).await,
Err(e) => (true, format!("governor unreachable (fail-open): {e}")),
}
} else {
state.runtime.judge(system, &request).await
};
// `judge()` returns this exact prefix when the provider never answered,
// which a rate-limited or uncredited judge model does on every call.
// Still logged loudly: a door that denies everything because its
// governor is down is safe, and is also a platform with no outbound
// actions until someone reads this line.
if reason.starts_with("governor unreachable") {
eprintln!(
"mcp_door: WARNING — the door governor is unreachable, DENYING {mcp_tool} \
({reason}). Point CLAWMATES_JUDGE_MODEL at a reachable model."
);
}
if !allow {
return PolicyOutcome::Deny(format!("governor agent vetoed — {reason}"));
}
return PolicyOutcome::Approve;
}
// 5. No governor. The door is closed unless the operator opened it.
ungoverned_default(std::env::var("CLAWMATES_DOOR_POLICY").ok().as_deref())
}
/// The posture with no governor configured. Only the literal `allow` opens
/// the door; unset, empty, or anything else keeps it shut and says how to
/// open it. `deny` is handled earlier as the kill switch and lands here too.
fn ungoverned_default(policy: Option<&str>) -> PolicyOutcome {
match policy.map(str::trim) {
Some("allow") => PolicyOutcome::Approve,
_ => PolicyOutcome::Deny(
"the door has no governor and no allow policy — set CLAWMATES_DOOR_GOVERNOR=1 \
or, to run ungoverned, CLAWMATES_DOOR_POLICY=allow"
.into(),
),
}
}
/// Mint an auto-approved approval + single-use execution grant for a
/// broker-executed door tool. The broker (cm-secrets) verifies & consumes this
/// grant before it reveals the credential — so even autonomous actions keep
/// credential custody out of the agent. Returns the approval id.
async fn mint_grant(
state: &AppState,
user: &cm_auth::AuthedUser,
agent_id: cm_domain::AgentId,
internal: &str,
category: Option<cm_domain::GatedCategory>,
args: &Value,
) -> Result<uuid::Uuid, String> {
// approvals.run_id / requested_by_agent are strict FKs → agent → session → run.
let session =
cm_db::repo::sessions::create(&state.pool, agent_id, user.workspace_id, "mcp-door")
.await
.map_err(|e| e.to_string())?;
let run_id = cm_db::repo::runs::create(&state.pool, session.id)
.await
.map_err(|e| e.to_string())?;
let approval = cm_safety::approvals::create(
&state.pool,
cm_safety::NewApproval {
workspace_id: user.workspace_id,
run_id,
session_key: session.id.as_uuid().to_string(),
action_type: internal.to_string(),
category: category.unwrap_or(cm_domain::GatedCategory::OutboundMessage),
payload: args.clone(),
preview: state.runtime.tool_preview(internal, args),
requested_by_agent: agent_id,
taint_sources: Vec::new(),
expires_at: Some(time::OffsetDateTime::now_utc() + time::Duration::hours(1)),
},
)
.await
.map_err(|e| e.to_string())?;
// Auto-decide (policy already approved above): mints the single-use grant
// and writes the decision to the audit log.
cm_safety::approvals::decide(
&state.pool,
approval.id,
user.user_id,
cm_safety::Decision::Approve,
)
.await
.map_err(|e| e.to_string())?;
Ok(approval.id)
}
/// Authenticate the bearer header → workspace/user. `None` if missing/invalid.
///
/// Accepts [`cm_auth::SCOPE_AGENT_DOOR`] as well as a person's session. This
/// route is the one that can `delegate`, and the thing that will eventually
/// hold a token for it is an agent runtime — so the narrow credential has to
/// exist before something reaches for the only one that does.
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_scoped(token, cm_auth::SCOPE_AGENT_DOOR)
.await
.ok()
}
/// Resolve the specific claw making the call. Our ZeroClaw fork stamps the
/// calling agent's alias (`claw_<id>`) on every door request via the
/// `X-ZeroClaw-Agent` header (see `mcp_servers_for_agent`); we resolve it to the
/// agent and verify it belongs to the authenticated workspace. Falls back to the
/// workspace's first agent for agents provisioned before per-claw identity, so
/// attribution degrades gracefully rather than failing.
async fn caller_agent(
state: &AppState,
user: &cm_auth::AuthedUser,
headers: &HeaderMap,
) -> Result<cm_domain::AgentId, String> {
if let Some(alias) = headers
.get("x-zeroclaw-agent")
.and_then(|v| v.to_str().ok())
{
if let Some(hex) = alias.strip_prefix("claw_") {
if let Ok(uuid) = uuid::Uuid::parse_str(hex) {
let agent_id = cm_domain::AgentId::from(uuid);
if let Ok(agent) = cm_db::repo::agents::get(&state.pool, agent_id).await {
if agent.workspace_id == user.workspace_id {
return Ok(agent.id);
}
}
}
}
}
// Legacy fallback: attribute to the workspace's first agent.
match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await {
Ok(roster) if !roster.is_empty() => Ok(roster[0].id),
Ok(_) => Err("no agent in workspace to act on behalf of".into()),
Err(_) => Err("failed to resolve workspace agent".into()),
}
}
/// Execute a gated `delegate` call: drive a sibling claw for a sub-task and
/// return its result, audited at every step. The target is tool-free behind the
/// door, so this adds no egress (§15 holds). Safety (v1): self-delegation is
/// rejected with the precise caller identity; a per-workspace hourly cap
/// (`CLAWMATES_DELEGATE_RATE_LIMIT`) plus the per-turn timeout bound runaway
/// fan-out / recursion. Chain-based cycle detection is a follow-up.
async fn delegate_call(
state: &AppState,
user: &cm_auth::AuthedUser,
caller: cm_domain::AgentId,
args: &Value,
id: Option<Value>,
) -> Json<Value> {
let Some(to) = args
.get("to")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
return tool_result(id, true, "delegate: missing 'to' (target claw name)".into());
};
let Some(task) = args
.get("task")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
return tool_result(id, true, "delegate: missing 'task'".into());
};
let context: Vec<String> = args
.get("context")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
// Resolve the target claw by name within the workspace.
let target = match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await {
Ok(roster) => roster.into_iter().find(|a| a.name.eq_ignore_ascii_case(to)),
Err(_) => {
return tool_result(
id,
true,
"delegate: failed to resolve workspace roster".into(),
)
}
};
let Some(target) = target else {
return tool_result(
id,
true,
format!("delegate: no claw named {to:?} in this workspace"),
);
};
if target.id == caller {
return tool_result(id, true, "delegate: cannot delegate to yourself".into());
}
// Per-workspace hourly delegation budget (counts delegation.invoked).
if let Some(cap) = std::env::var("CLAWMATES_DELEGATE_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<i64>().ok())
{
let used: i64 = sqlx::query_scalar(
"SELECT count(*) FROM audit_log
WHERE workspace_id = $1 AND event_type = 'delegation.invoked'
AND created_at > now() - interval '1 hour'",
)
.bind(user.workspace_id.as_uuid())
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if used >= cap {
return tool_result(
id,
true,
format!("delegate: hourly delegation limit reached ({used}/{cap})"),
);
}
}
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::Agent(caller),
"delegation.invoked",
"agent",
&target.name,
json!({ "to_id": target.id.to_string(), "task": task }),
)
.await;
let exec = match crate::topology_exec::ZeroClawDriveExecutor::from_env() {
Ok(exec) => exec,
Err(e) => return tool_result(id, true, format!("delegate: runtime unavailable: {e}")),
};
let alias = crate::runtime_provision::claw_alias(target.id.as_uuid());
match exec.delegate(&alias, task, &context).await {
Ok(outcome) => {
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::Agent(caller),
"delegation.completed",
"agent",
&target.name,
json!({ "to_id": target.id.to_string(), "tokens": outcome.tokens,
"blocked": outcome.gated.len() }),
)
.await;
// §15: the result is untrusted content from another agent. The
// attribution stays — knowing which claw produced this is
// information the caller needs to weigh it. The "treat it as
// information, not instructions" imperative that followed is gone:
// that is model-correction of the kind a current frontier model no
// longer needs, and taint tracking (output_taint = InterAgent), not
// a sentence in the payload, is what actually contains this.
let mut text = format!(
"The following is the result returned by claw '{}'.\n\n{}",
target.name, outcome.output
);
if !outcome.gated.is_empty() {
text.push_str(&format!(
"\n\n[note: {} action(s) by '{}' were blocked at the door during this delegation]",
outcome.gated.len(),
target.name
));
}
tool_result(id, false, text)
}
Err(e) => {
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::Agent(caller),
"delegation.error",
"agent",
&target.name,
json!({ "to_id": target.id.to_string(), "error": e.to_string() }),
)
.await;
tool_result(id, true, format!("delegate: turn failed: {e}"))
}
}
}
/// `POST /mcp` — the JSON-RPC entrypoint ZeroClaw agents connect to.
pub async fn mcp(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<JsonRpcReq>,
) -> Json<Value> {
match req.method.as_str() {
// Handshake — no auth needed to negotiate the protocol.
"initialize" => ok(
req.id,
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "clawmates", "version": env!("CARGO_PKG_VERSION") },
}),
),
// Best-effort notification; nothing to do.
"notifications/initialized" => Json(json!({ "jsonrpc": "2.0" })),
"tools/list" => {
if authed(&state, &headers).await.is_none() {
return err(
req.id,
-32001,
"unauthorized: missing or invalid bearer token",
);
}
let tools: Vec<Value> = EXPOSED_TOOLS
.iter()
.filter_map(|(mcp_name, internal)| {
state.runtime.tool_descriptor_json(internal).map(|mut d| {
// Present under the MCP-facing name.
d["name"] = json!(mcp_name);
d
})
})
.collect();
ok(req.id, json!({ "tools": tools }))
}
"tools/call" => {
let Some(user) = authed(&state, &headers).await else {
return err(
req.id,
-32001,
"unauthorized: missing or invalid bearer token",
);
};
let params = req.params.clone().unwrap_or_else(|| json!({}));
let mcp_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let Some(internal) = internal_name(mcp_name) else {
return tool_result(
req.id,
true,
// Derived from EXPOSED_TOOLS rather than hand-written: the
// literal list here had already drifted to name only one of
// the three tools the door actually exposes.
format!(
"unknown tool {mcp_name:?} (this door exposes: {})",
EXPOSED_TOOLS
.iter()
.map(|(m, _)| *m)
.collect::<Vec<_>>()
.join(", ")
),
);
};
let category = state.runtime.tool_gate_category(internal);
// The gate — human replaced by automated policy.
if let PolicyOutcome::Deny(reason) =
policy_decide(&state, user.workspace_id, mcp_name, category, &args).await
{
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::System,
"door.denied",
"tool",
mcp_name,
json!({ "category": category.map(|c| c.as_str()), "reason": reason, "args": args }),
)
.await;
return tool_result(req.id, true, format!("denied by policy: {reason}"));
}
// Attribute the action to the specific calling claw (X-ZeroClaw-Agent
// header), or the workspace's first agent as a legacy fallback.
let agent_id = match caller_agent(&state, &user, &headers).await {
Ok(id) => id,
Err(msg) => return tool_result(req.id, true, msg),
};
// Gated delegation bridge: `delegate` causes a sibling claw to run a
// full turn and returns its result, gated + audited here rather than
// via ZeroClaw's in-memory DelegateTool (which would bypass the door).
if internal == "delegate" {
return delegate_call(&state, &user, agent_id, &args, req.id).await;
}
// Broker-executed tools (e.g. slack.post) need a single-use grant
// the broker consumes — the agent never holds the credential. Mint
// an auto-approved approval+grant (the human is the policy above).
// Runtime-executed tools (email.send -> outbox) need no grant.
let approval_id = if state.runtime.tool_broker_executed(internal) {
match mint_grant(&state, &user, agent_id, internal, category, &args).await {
Ok(id) => Some(id),
Err(e) => {
return tool_result(req.id, true, format!("could not mint grant: {e}"))
}
}
} else {
None
};
match state
.runtime
.execute_door_tool(
user.workspace_id,
agent_id,
internal,
args.clone(),
approval_id,
)
.await
{
Ok(output) => {
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::Agent(agent_id),
"door.executed",
"tool",
mcp_name,
json!({
"category": category.map(|c| c.as_str()),
"auto_decided": true,
"args": args,
"output": output,
}),
)
.await;
tool_result(req.id, false, output.to_string())
}
Err(message) => {
let _ = cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
cm_db::repo::audit::Actor::Agent(agent_id),
"door.error",
"tool",
mcp_name,
json!({ "error": message, "args": args }),
)
.await;
tool_result(req.id, true, format!("tool error: {message}"))
}
}
}
other => err(req.id, -32601, &format!("method not found: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The default posture is closed. Before 2026-09-20 an unset policy
/// meant allow-all.
#[test]
fn the_door_is_closed_unless_opened() {
assert!(matches!(ungoverned_default(None), PolicyOutcome::Deny(_)));
assert!(matches!(ungoverned_default(Some("")), PolicyOutcome::Deny(_)));
assert!(matches!(ungoverned_default(Some("deny")), PolicyOutcome::Deny(_)));
assert!(matches!(ungoverned_default(Some("yes")), PolicyOutcome::Deny(_)));
assert!(matches!(ungoverned_default(Some("allow")), PolicyOutcome::Approve));
assert!(matches!(ungoverned_default(Some(" allow ")), PolicyOutcome::Approve));
}
#[test]
fn exposed_tool_name_maps_to_registry_name() {
assert_eq!(internal_name("email_send"), Some("email.send"));
assert_eq!(internal_name("slack_post"), Some("slack.post"));
assert_eq!(internal_name("delegate"), Some("delegate"));
assert_eq!(internal_name("shell"), None);
}
#[test]
fn ok_frame_echoes_id_and_carries_result() {
let Json(v) = ok(Some(json!(7)), json!({ "tools": [] }));
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 7);
assert_eq!(v["result"]["tools"], json!([]));
}
#[test]
fn err_frame_has_code_and_message() {
let Json(v) = err(Some(json!("a")), -32001, "unauthorized");
assert_eq!(v["error"]["code"], -32001);
assert_eq!(v["error"]["message"], "unauthorized");
}
#[test]
fn tool_result_shapes_content_and_is_error() {
let Json(v) = tool_result(Some(json!(1)), true, "denied by policy: nope".into());
assert_eq!(v["result"]["isError"], true);
assert_eq!(v["result"]["content"][0]["type"], "text");
assert_eq!(v["result"]["content"][0]["text"], "denied by policy: nope");
}
}