use std::path::Path;
use std::sync::Arc;
use agent_bridle::{Caveats, Grant, Registry, ToolError};
use newt_core::router::{Router, Tier};
use newt_inference::BackendRegistry;
use newt_mcp_client::McpToolset;
use serde_json::Value;
use tokio::sync::Mutex;
use crate::caveats::GrantedCaveats;
use crate::server::McpServer;
pub fn register_handlers(
server: &mut McpServer,
registry: Arc<BackendRegistry>,
router: Arc<Router>,
toolset: Arc<Mutex<McpToolset>>,
persona_tools: Arc<Option<Vec<String>>>,
) {
let granted = GrantedCaveats::load();
granted.warn_to_stderr();
register_initialize(server);
register_tools_list(
server,
toolset.clone(),
persona_tools.clone(),
&granted.caveats.fs_read,
);
register_tools_call(
server,
registry,
router,
bridle_registry(),
Arc::new(granted.caveats),
toolset,
persona_tools,
);
}
fn register_initialize(server: &mut McpServer) {
server.register("initialize", |_params| async move {
Ok(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": {
"name": "newt-mcp-server",
"version": env!("CARGO_PKG_VERSION")
}
}))
});
}
fn filter_persona_tool_list(tools: Vec<Value>, allow: Option<&[String]>) -> Vec<Value> {
let Some(allow) = allow else { return tools };
tools
.into_iter()
.filter(|t| match t.get("name").and_then(Value::as_str) {
Some(name) => newt_core::agentic::persona_tool_allowed(name, allow),
None => true,
})
.collect()
}
fn register_tools_list(
server: &mut McpServer,
toolset: Arc<Mutex<McpToolset>>,
persona_tools: Arc<Option<Vec<String>>>,
read_scope: &agent_bridle::Scope<String>,
) {
let legacy_git_allowed = newt_core::agentic::check_git_read_scope("open", read_scope).is_ok();
server.register("tools/list", move |_params| {
let toolset = toolset.clone();
let persona_tools = persona_tools.clone();
async move {
let Value::Array(mut tools) = tool_definitions() else {
unreachable!("tool_definitions always returns a JSON array");
};
if !legacy_git_allowed {
tools.retain(|tool| tool["name"] != "git");
}
tools.extend(toolset.lock().await.mcp_tool_list());
let tools = filter_persona_tool_list(tools, persona_tools.as_deref());
Ok(serde_json::json!({ "tools": tools }))
}
});
}
fn tool_definitions() -> Value {
let tools = vec![
serde_json::json!({
"name": "code_read",
"description": "Read a file's contents",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path to read"
}
},
"required": ["path"]
}
}),
serde_json::json!({
"name": "code_edit",
"description": "Apply a unified diff patch to a file",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path to edit"
},
"patch": {
"type": "string",
"description": "Unified diff to apply"
}
},
"required": ["path", "patch"]
}
}),
serde_json::json!({
"name": "code_search",
"description": "Search files for a regex pattern",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Regex pattern"
},
"path": {
"type": "string",
"description": "Root directory to search"
}
},
"required": ["query", "path"]
}
}),
serde_json::json!({
"name": "git",
"description": "Run a git operation via the embedded engine (grit-lib), gated by GitCaveats derived from the granted Caveats leash. Requires unrestricted fs_read because legacy repository reads are not path-confined. ops: status | log | diff | add | commit | branch. Local-only; network ops (clone/fetch/push) are fail-closed. A write op (add/commit/branch) under a read-only grant is DENIED (isError). Returns structured JSON.",
"inputSchema": {
"type": "object",
"properties": {
"op": { "type": "string", "enum": ["status", "log", "diff", "add", "commit", "branch"] },
"repo": { "type": "string", "description": "Repository path (default: current directory)" },
"limit": { "type": "integer", "description": "log: max commits (default 20)" },
"staged": { "type": "boolean", "description": "diff: staged (index vs HEAD) instead of worktree (default false)" },
"paths": { "type": "array", "items": { "type": "string" }, "description": "add: repo-relative paths to stage" },
"message": { "type": "string", "description": "commit: commit message" },
"name": { "type": "string", "description": "branch: new branch name" }
},
"required": ["op"]
}
}),
serde_json::json!({
"name": "goal_run",
"description": "Run a tier-routed inference turn",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The prompt"
},
"tier": {
"type": "string",
"description": "Optional tier override",
"enum": ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
}
},
"required": ["prompt"]
}
}),
serde_json::json!({
"name": "fs_list",
"description": "List directory contents (dirs first, then files, alphabetical). Returns name, kind, and size_bytes for each entry.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list"
}
},
"required": ["path"]
}
}),
serde_json::json!({
"name": "shell_run",
"description": "Run a shell command and capture stdout/stderr. Returns exit_code, stdout, stderr, timed_out. Max timeout 300s. CAVEATS-CONFINED: the command runs inside agent-bridle's brush shell under the granted Caveats leash (from ~/.newt/config.toml [caveats]); a command outside the granted exec/fs scope is DENIED (isError) rather than executed.",
"inputSchema": {
"type": "object",
"properties": {
"cmd": {
"type": "string",
"description": "Shell command to execute, confined in-process by the agent-bridle capability interceptor (exec + fs scopes)"
},
"cwd": {
"type": "string",
"description": "Working directory (default: current directory)"
},
"timeout_secs": {
"type": "integer",
"description": "Timeout in seconds (default 30, max 300)"
}
},
"required": ["cmd"]
}
}),
serde_json::json!({
"name": "web_fetch",
"description": "Fetch an http(s) URL and return its main content as clean markdown. CAVEATS-CONFINED: reachable hosts are gated by the granted `net` scope (host allowlist + SSRF screen); an out-of-scope host is DENIED (isError) rather than fetched. Returns { url, final_url, status, title, markdown } — the markdown is untrusted page content.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The http(s) URL to fetch"
},
"max_bytes": {
"type": "integer",
"description": "Optional cap on bytes downloaded (default 5 MiB, max 25 MiB)"
}
},
"required": ["url"]
}
}),
];
Value::Array(tools)
}
#[allow(clippy::too_many_arguments)]
fn register_tools_call(
server: &mut McpServer,
registry: Arc<BackendRegistry>,
router: Arc<Router>,
bridle: Arc<Registry>,
granted: Arc<Caveats>,
toolset: Arc<Mutex<McpToolset>>,
persona_tools: Arc<Option<Vec<String>>>,
) {
let grant = Arc::new(bridle.mint_grant((*granted).clone()));
server.register("tools/call", move |params| {
let registry = registry.clone();
let router = router.clone();
let bridle = bridle.clone();
let granted = granted.clone();
let grant = grant.clone();
let toolset = toolset.clone();
let persona_tools = persona_tools.clone();
async move {
let name = params
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
if let Some(allow) = persona_tools.as_deref() {
if !newt_core::agentic::persona_tool_allowed(&name, allow) {
anyhow::bail!("tool `{name}` is not permitted by the active persona");
}
}
match name.as_str() {
"code_read" => handle_code_read(&arguments),
"code_edit" => handle_code_edit(&arguments),
"code_search" => handle_code_search(&arguments),
"goal_run" => handle_goal_run(&arguments, ®istry, &router).await,
"fs_list" => handle_fs_list(&arguments),
"git" => handle_git(&arguments, &granted),
"shell_run" => Ok(handle_shell_run(arguments, &bridle, &grant).await),
"web_fetch" => Ok(handle_web_fetch(arguments, &bridle, &grant).await),
other if newt_mcp_client::split_namespaced(other).is_some() => {
let mut toolset = toolset.lock().await;
if !toolset.handles(other) {
anyhow::bail!("unknown tool: {other}");
}
Ok(mcp_text_content(&toolset.call(other, &arguments).await))
}
other => anyhow::bail!("unknown tool: {other}"),
}
}
});
}
fn handle_git(args: &Value, granted: &Caveats) -> anyhow::Result<Value> {
use newt_git::{Author, DiffSpec, GitEngine};
let op = args
.get("op")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: op"))?;
let repo = args.get("repo").and_then(|v| v.as_str()).unwrap_or(".");
let caps = newt_core::git_caveats::GitCaveats::from_session(granted);
let eng = GitEngine::open(Path::new(repo), &granted.fs_read)
.map_err(|e| anyhow::anyhow!("git open: {e}"))?;
let result: Value = match op {
"status" => serde_json::to_value(eng.status(&caps).map_err(gerr)?)?,
"log" => {
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize;
serde_json::to_value(eng.log(&caps, limit, None, &[]).map_err(gerr)?)?
}
"diff" => {
let spec = if args.get("staged").and_then(Value::as_bool).unwrap_or(false) {
DiffSpec::Staged
} else {
DiffSpec::Worktree
};
serde_json::to_value(eng.diff(&caps, spec, &[], false).map_err(gerr)?)?
}
"add" => {
let paths: Vec<String> = args
.get("paths")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
serde_json::to_value(eng.add(&caps, &paths).map_err(gerr)?)?
}
"commit" => {
let message = args
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("git commit requires 'message'"))?;
let identity = newt_core::AgentIdentity::resolve().unwrap_or_default();
let author = Author {
name: args
.get("author_name")
.and_then(|v| v.as_str())
.unwrap_or(identity.name.as_str())
.to_string(),
email: args
.get("author_email")
.and_then(|v| v.as_str())
.unwrap_or(identity.email.as_str())
.to_string(),
};
serde_json::to_value(eng.commit(&caps, message, &author).map_err(gerr)?)?
}
"branch" => {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("git branch requires 'name'"))?;
serde_json::to_value(eng.branch(&caps, name).map_err(gerr)?)?
}
other => anyhow::bail!("unknown git op: {other}"),
};
Ok(mcp_text_content(&serde_json::to_string_pretty(&result)?))
}
fn gerr(e: newt_git::GitError) -> anyhow::Error {
anyhow::anyhow!("git: {e}")
}
fn handle_code_read(args: &Value) -> anyhow::Result<Value> {
let path = args
.get("path")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: path"))?;
let content = newt_tools::read(Path::new(path))?;
Ok(mcp_text_content(&content))
}
fn handle_code_edit(args: &Value) -> anyhow::Result<Value> {
let path = args
.get("path")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: path"))?;
let patch = args
.get("patch")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: patch"))?;
newt_tools::edit(Path::new(path), patch)?;
Ok(mcp_text_content(&format!("patched {path}")))
}
fn handle_code_search(args: &Value) -> anyhow::Result<Value> {
let query = args
.get("query")
.and_then(|q| q.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: query"))?;
let path = args
.get("path")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: path"))?;
let hits = newt_tools::search(query, Path::new(path))?;
let lines: Vec<String> = hits
.iter()
.map(|h| format!("{}:{}: {}", h.path, h.line_number, h.line))
.collect();
let text = if lines.is_empty() {
"no matches".to_string()
} else {
lines.join("\n")
};
Ok(mcp_text_content(&text))
}
fn handle_fs_list(args: &Value) -> anyhow::Result<Value> {
let path = args
.get("path")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: path"))?;
let entries = newt_tools::list_dir(Path::new(path))?;
Ok(mcp_text_content(&serde_json::to_string_pretty(&entries)?))
}
async fn handle_shell_run(args: Value, bridle: &Registry, grant: &Grant) -> Value {
if args.get("cmd").and_then(Value::as_str).is_none() {
return mcp_error_content("missing required argument: cmd (must be a string)");
}
match bridle.dispatch("shell", args, grant).await {
Ok(envelope) if is_denied(&envelope) => mcp_error_content(&denial_reason(&envelope)),
Ok(envelope) => match serde_json::to_string_pretty(&envelope) {
Ok(text) => mcp_text_content(&text),
Err(e) => mcp_error_content(&format!("failed to serialize shell result: {e}")),
},
Err(e @ ToolError::Denied { .. }) => mcp_error_content(&e.to_string()),
Err(e) => mcp_error_content(&e.to_string()),
}
}
async fn handle_web_fetch(args: Value, bridle: &Registry, grant: &Grant) -> Value {
if args.get("url").and_then(Value::as_str).is_none() {
return mcp_error_content("missing required argument: url (must be a string)");
}
match bridle.dispatch("web_fetch", args, grant).await {
Ok(result) => match serde_json::to_string_pretty(&result) {
Ok(text) => mcp_text_content(&text),
Err(e) => mcp_error_content(&format!("failed to serialize web_fetch result: {e}")),
},
Err(e) => mcp_error_content(&e.to_string()),
}
}
fn is_denied(envelope: &Value) -> bool {
envelope
.get("denied")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn denial_reason(envelope: &Value) -> String {
let reasons: Vec<String> = envelope
.get("denials")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("reason").and_then(Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
if reasons.is_empty() {
"denied: the capability leash refused an operation".to_string()
} else {
reasons.join("; ")
}
}
fn parse_tier(s: &str) -> anyhow::Result<Tier> {
match s.to_ascii_uppercase().as_str() {
"FAST" => Ok(Tier::Fast),
"STANDARD" => Ok(Tier::Standard),
"COMPLEX" => Ok(Tier::Complex),
"REVIEW" => Ok(Tier::Review),
other => anyhow::bail!("invalid tier: {other} (expected FAST|STANDARD|COMPLEX|REVIEW)"),
}
}
async fn handle_goal_run(
args: &Value,
registry: &BackendRegistry,
router: &Router,
) -> anyhow::Result<Value> {
let prompt = args
.get("prompt")
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required argument: prompt"))?
.to_string();
let tier = match args.get("tier").and_then(|t| t.as_str()) {
Some(s) => parse_tier(s)?,
None => router.classify(&prompt),
};
let backend = registry
.pick(tier)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let chat = newt_inference::ChatRequest::new().user(prompt);
let reply = backend
.complete(chat)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(mcp_text_content(&format!(
"[{}] {}",
reply.model_id, reply.content
)))
}
fn mcp_text_content(text: &str) -> Value {
serde_json::json!({
"content": [{
"type": "text",
"text": text
}]
})
}
fn mcp_error_content(reason: &str) -> Value {
serde_json::json!({
"content": [{
"type": "text",
"text": reason
}],
"isError": true
})
}
#[cfg(test)]
fn bridle_registry() -> Arc<Registry> {
Arc::new(
Registry::builder()
.tool(Arc::new(agent_bridle::ShellTool::new()))
.tool(Arc::new(agent_bridle::WebFetchTool::new()))
.build(),
)
}
#[cfg(not(test))]
fn bridle_registry() -> Arc<Registry> {
Arc::new(agent_bridle::registry())
}
#[cfg(test)]
mod tests {
use super::*;
async fn rpc(request: &Value) -> Value {
rpc_with(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
request,
)
.await
}
async fn rpc_with(
registry: Arc<BackendRegistry>,
router: Arc<Router>,
request: &Value,
) -> Value {
rpc_with_persona(registry, router, McpToolset::empty(), None, request).await
}
async fn rpc_with_persona(
registry: Arc<BackendRegistry>,
router: Arc<Router>,
toolset: McpToolset,
persona_tools: Option<Vec<String>>,
request: &Value,
) -> Value {
let mut server = McpServer::new();
register_handlers(
&mut server,
registry,
router,
Arc::new(Mutex::new(toolset)),
Arc::new(persona_tools),
);
let input = format!("{}\n", serde_json::to_string(request).unwrap());
let mut output: Vec<u8> = Vec::new();
server.run(input.as_bytes(), &mut output).await.unwrap();
let text = String::from_utf8(output).unwrap();
serde_json::from_str(text.trim()).unwrap()
}
async fn rpc_with_caveats(granted: Caveats, request: &Value) -> Value {
let mut server = McpServer::new();
let toolset = Arc::new(Mutex::new(McpToolset::empty()));
let persona_tools = Arc::new(None);
register_initialize(&mut server);
register_tools_list(
&mut server,
toolset.clone(),
persona_tools.clone(),
&granted.fs_read,
);
register_tools_call(
&mut server,
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
bridle_registry(),
Arc::new(granted),
toolset,
persona_tools,
);
let input = format!("{}\n", serde_json::to_string(request).unwrap());
let mut output: Vec<u8> = Vec::new();
server.run(input.as_bytes(), &mut output).await.unwrap();
let text = String::from_utf8(output).unwrap();
serde_json::from_str(text.trim()).unwrap()
}
fn echo_only_grant() -> Caveats {
use agent_bridle::{CountBound, Scope};
#[cfg(windows)]
let exec = Scope::only(["cmd.exe".to_string()]);
#[cfg(not(windows))]
let exec = Scope::only(["echo".to_string()]);
Caveats {
exec,
max_calls: CountBound::AtMost(8),
..Caveats::top()
}
}
fn echo_command() -> &'static str {
#[cfg(windows)]
{
"cmd.exe /c echo bridled"
}
#[cfg(not(windows))]
{
"echo bridled"
}
}
#[tokio::test]
async fn initialize_returns_protocol_version() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}
}))
.await;
let result = &resp["result"];
assert_eq!(result["protocolVersion"], "2024-11-05");
assert_eq!(result["serverInfo"]["name"], "newt-mcp-server");
assert!(result["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn tools_list_returns_expected_tools() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}
}))
.await;
let tools = resp["result"]["tools"].as_array().unwrap();
assert!(
tools.len() >= 4,
"expected at least 4 tools, got {}",
tools.len()
);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"code_read"));
assert!(names.contains(&"code_edit"));
assert!(names.contains(&"code_search"));
assert!(names.contains(&"goal_run"));
assert!(names.contains(&"fs_list"));
assert!(names.contains(&"shell_run"));
}
#[tokio::test]
async fn tools_list_has_input_schemas() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}
}))
.await;
let tools = resp["result"]["tools"].as_array().unwrap();
for tool in tools {
assert!(
tool["inputSchema"].is_object(),
"tool {} missing inputSchema",
tool["name"]
);
}
}
#[tokio::test]
async fn persona_restricts_tools_list_to_its_allow_list() {
let resp = rpc_with_persona(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
McpToolset::empty(),
Some(vec!["code_read".to_string()]),
&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}
}),
)
.await;
let names: Vec<&str> = resp["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["code_read"], "only the allowed tool is listed");
}
#[tokio::test]
async fn no_persona_advertises_the_full_catalog_unrestricted() {
let resp = rpc_with_persona(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
McpToolset::empty(),
None,
&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}
}),
)
.await;
let names: Vec<&str> = resp["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"code_read"));
assert!(names.contains(&"shell_run"));
assert!(names.contains(&"web_fetch"));
}
#[tokio::test]
async fn persona_rejects_a_call_to_a_tool_outside_its_allow_list() {
let resp = rpc_with_persona(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
McpToolset::empty(),
Some(vec!["code_read".to_string()]),
&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "shell_run", "arguments": { "command": "echo hi" } }
}),
)
.await;
let err = resp["error"]["message"].as_str().unwrap_or_default();
assert!(
err.contains("not permitted by the active persona"),
"got: {resp}"
);
}
#[tokio::test]
async fn persona_allows_a_call_to_a_tool_inside_its_allow_list() {
let resp = rpc_with_persona(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
McpToolset::empty(),
Some(vec!["fs_list".to_string()]),
&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "fs_list", "arguments": { "path": "." } }
}),
)
.await;
assert!(resp.get("error").is_none(), "got: {resp}");
}
#[tokio::test]
async fn namespaced_call_against_an_unconnected_toolset_fails_closed() {
let resp = rpc_with_persona(
Arc::new(BackendRegistry::new()),
Arc::new(Router::new()),
McpToolset::empty(),
Some(vec!["modulex__routine_run".to_string()]),
&serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "modulex__routine_run", "arguments": { "routine": "morning" } }
}),
)
.await;
let err = resp["error"]["message"].as_str().unwrap_or_default();
assert!(err.contains("unknown tool"), "got: {resp}");
}
#[tokio::test]
async fn code_read_happy_path() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "hello from newt\n").unwrap();
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 10, "method": "tools/call",
"params": {
"name": "code_read",
"arguments": { "path": tmp.path().to_str().unwrap() }
}
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("hello from newt"));
}
#[tokio::test]
async fn code_read_missing_file() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 11, "method": "tools/call",
"params": {
"name": "code_read",
"arguments": { "path": "/tmp/newt-mcp-no-such-file-xyz" }
}
}))
.await;
assert!(resp["error"].is_object(), "expected error, got: {resp}");
assert_eq!(resp["error"]["code"], -32603);
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
assert!(std::process::Command::new("git")
.current_dir(dir)
.args(args)
.status()
.unwrap()
.success());
}
fn temp_repo() -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
let p = d.path();
run_git(p, &["init", "-q", "-b", "main"]);
std::fs::write(p.join("a.txt"), "hello\n").unwrap();
run_git(p, &["add", "a.txt"]);
run_git(
p,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e",
"commit",
"-q",
"-m",
"first",
],
);
d
}
#[tokio::test]
async fn git_status_returns_structured_json() {
let repo = temp_repo();
let resp = rpc_with_caveats(
Caveats::top(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 40, "method": "tools/call",
"params": {
"name": "git",
"arguments": { "op": "status", "repo": repo.path().to_str().unwrap() }
}
}),
)
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
let v: Value = serde_json::from_str(text).unwrap();
assert_eq!(v["branch"], "main");
assert_eq!(v["clean"], true);
}
#[tokio::test]
async fn scoped_git_is_not_advertised_or_dispatched() {
let repo = temp_repo();
let mut granted = Caveats::top();
granted.fs_read = agent_bridle::Scope::only([repo.path().to_string_lossy().into_owned()]);
let request = serde_json::json!({
"jsonrpc": "2.0", "id": 40, "method": "tools/call",
"params": {
"name": "git",
"arguments": { "op": "status", "repo": repo.path().to_str().unwrap() }
}
});
let control = rpc_with_caveats(Caveats::top(), &request).await;
assert!(control["result"].is_object(), "{control}");
let denied = rpc_with_caveats(granted.clone(), &request).await;
assert!(
denied["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("fs_read")),
"scoped MCP Git must refuse before discovery: {denied}"
);
let listed = rpc_with_caveats(
granted,
&serde_json::json!({
"jsonrpc": "2.0", "id": 41, "method": "tools/list", "params": {}
}),
)
.await;
assert!(listed["result"]["tools"]
.as_array()
.unwrap()
.iter()
.all(|tool| tool["name"] != "git"));
}
#[tokio::test]
async fn git_commit_denied_under_readonly_grant() {
use agent_bridle::Scope;
let repo = temp_repo();
let readonly = Caveats {
fs_write: Scope::none(),
..Caveats::top()
};
let resp = rpc_with_caveats(
readonly,
&serde_json::json!({
"jsonrpc": "2.0", "id": 41, "method": "tools/call",
"params": {
"name": "git",
"arguments": {
"op": "commit",
"repo": repo.path().to_str().unwrap(),
"message": "should be refused"
}
}
}),
)
.await;
assert!(
resp["error"].is_object(),
"read-only grant must refuse git commit, got: {resp}"
);
}
#[tokio::test]
async fn code_search_happy_path() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.txt"), "needle in hay\nhay only\n").unwrap();
std::fs::write(tmp.path().join("b.txt"), "more hay\nneedle again\n").unwrap();
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 20, "method": "tools/call",
"params": {
"name": "code_search",
"arguments": {
"query": "needle",
"path": tmp.path().to_str().unwrap()
}
}
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("needle"), "expected hits, got: {text}");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "expected 2 hits, got: {text}");
}
#[tokio::test]
async fn code_search_no_matches() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.txt"), "nothing here\n").unwrap();
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 21, "method": "tools/call",
"params": {
"name": "code_search",
"arguments": {
"query": "zzz_absent",
"path": tmp.path().to_str().unwrap()
}
}
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert_eq!(text, "no matches");
}
#[tokio::test]
async fn code_edit_happy_path() {
let tmp = tempfile::TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
std::fs::write(&file, "line1\nline2\n").unwrap();
let patch = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,2 +1,2 @@
line1
-line2
+edited
";
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 30, "method": "tools/call",
"params": {
"name": "code_edit",
"arguments": {
"path": file.to_str().unwrap(),
"patch": patch
}
}
}))
.await;
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("patched"), "expected success, got: {text}");
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "line1\nedited\n");
}
#[tokio::test]
async fn code_edit_bad_patch() {
let tmp = tempfile::TempDir::new().unwrap();
let file = tmp.path().join("hello.txt");
std::fs::write(&file, "actual\n").unwrap();
let patch = "\
--- a/hello.txt
+++ b/hello.txt
@@ -1,1 +1,1 @@
WRONG_CONTEXT
";
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 31, "method": "tools/call",
"params": {
"name": "code_edit",
"arguments": {
"path": file.to_str().unwrap(),
"patch": patch
}
}
}))
.await;
assert!(resp["error"].is_object(), "expected error, got: {resp}");
assert_eq!(resp["error"]["code"], -32603);
}
#[tokio::test]
async fn unknown_tool_returns_error() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 50, "method": "tools/call",
"params": {
"name": "nonexistent_tool",
"arguments": {}
}
}))
.await;
assert!(resp["error"].is_object(), "expected error, got: {resp}");
assert_eq!(resp["error"]["code"], -32603);
assert!(resp["error"]["message"]
.as_str()
.unwrap()
.contains("unknown tool"));
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_run_in_scope_echo_succeeds() {
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 60, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": echo_command() }
}
}),
)
.await;
assert!(
resp["error"].is_null(),
"result must be in-band, not a transport error: {resp}"
);
let result = &resp["result"];
assert!(
result["isError"].as_bool() != Some(true),
"an in-scope echo must succeed, not error: {result}"
);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("bridled"),
"the in-scope echo output must be returned: {text}"
);
}
#[cfg(windows)]
#[tokio::test]
async fn shell_run_exec_allowlist_is_refused_on_windows() {
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 60, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": echo_command() }
}
}),
)
.await;
assert!(
resp["error"].is_null(),
"result must be in-band, not a transport error: {resp}"
);
let result = &resp["result"];
assert_eq!(result["isError"].as_bool(), Some(true), "{result}");
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Exec axis") && !text.contains("bridled"),
"the allowlisted echo must be refused on the Exec axis, not run: {text}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn shell_run_out_of_scope_rm_is_denied() {
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 61, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": "rm -rf /tmp/newt-bridle-should-not-run" }
}
}),
)
.await;
assert!(
resp["error"].is_null(),
"stub result must be in-band, not a transport error: {resp}"
);
let result = &resp["result"];
assert_eq!(
result["isError"], true,
"out-of-scope rm must surface as an MCP tool error: {result}"
);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("not within the granted authority"),
"out-of-scope rm must surface a capability denial: {text}"
);
}
#[test]
fn denial_detection_reads_structured_fields_not_stderr() {
let denied = serde_json::json!({
"exit_code": 126,
"stdout": "all good, nothing denied here",
"stderr": "",
"denied": true,
"denials": [
{ "kind": "exec", "target": "rm", "reason": "rm is not within the granted authority" }
]
});
assert!(
is_denied(&denied),
"structured denied:true must be detected"
);
assert_eq!(
denial_reason(&denied),
"rm is not within the granted authority"
);
let clean = serde_json::json!({
"exit_code": 0,
"stdout": "execution denied is not within the granted authority",
"stderr": "open denied",
});
assert!(
!is_denied(&clean),
"absence of denied:true must mean not denied, regardless of output"
);
let multi = serde_json::json!({
"denied": true,
"denials": [
{ "kind": "exec", "target": "rm", "reason": "exec rm denied" },
{ "kind": "open", "target": "/etc/x", "reason": "open /etc/x denied" }
]
});
assert_eq!(denial_reason(&multi), "exec rm denied; open /etc/x denied");
}
#[cfg(unix)]
#[tokio::test]
async fn shell_run_exec_bypass_is_denied_and_program_does_not_run() {
let tmp = tempfile::TempDir::new().unwrap();
let marker = tmp.path().join("exec-bypass-marker");
let marker_str = marker.to_str().unwrap();
assert!(!marker.exists(), "precondition: marker must not exist yet");
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 65, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": format!("touch {marker_str}") }
}
}),
)
.await;
assert!(
resp["error"].is_null(),
"denial must be in-band, not transport: {resp}"
);
let result = &resp["result"];
assert_eq!(
result["isError"], true,
"out-of-scope touch must be denied (isError): {result}"
);
assert!(
!marker.exists(),
"exec-bypass: touch must NOT have created the marker {marker_str}"
);
let victim = tmp.path().join("victim");
std::fs::write(&victim, "do not delete me").unwrap();
let victim_str = victim.to_str().unwrap();
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 66, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": format!("rm {victim_str}") }
}
}),
)
.await;
assert_eq!(
resp["result"]["isError"], true,
"out-of-scope rm must be denied: {}",
resp["result"]
);
assert!(
victim.exists(),
"exec-bypass: rm must NOT have deleted the victim {victim_str}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn shell_run_bin_rm_path_separator_is_denied() {
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 62, "method": "tools/call",
"params": {
"name": "shell_run",
"arguments": { "cmd": "/bin/rm -rf /tmp/newt-bridle-should-not-run" }
}
}),
)
.await;
let result = &resp["result"];
assert_eq!(
result["isError"], true,
"path-separator rm must surface as an MCP tool error: {result}"
);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("not within the granted authority"),
"path-separator rm must surface a capability denial: {text}"
);
}
#[tokio::test]
async fn shell_run_missing_cmd_is_in_band_error() {
let resp = rpc_with_caveats(
echo_only_grant(),
&serde_json::json!({
"jsonrpc": "2.0", "id": 63, "method": "tools/call",
"params": { "name": "shell_run", "arguments": {} }
}),
)
.await;
assert!(
resp["error"].is_null(),
"should be in-band, not transport: {resp}"
);
assert_eq!(resp["result"]["isError"], true);
assert!(resp["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("cmd"));
}
#[tokio::test]
async fn shell_run_description_notes_caveats_confinement() {
let resp = rpc(&serde_json::json!({
"jsonrpc": "2.0", "id": 64, "method": "tools/list", "params": {}
}))
.await;
let tools = resp["result"]["tools"].as_array().unwrap();
let shell = tools
.iter()
.find(|t| t["name"] == "shell_run")
.expect("shell_run present");
let desc = shell["description"].as_str().unwrap();
assert!(
desc.contains("CAVEATS-CONFINED") || desc.to_lowercase().contains("caveats"),
"shell_run description should note Caveats confinement: {desc}"
);
}
#[test]
fn parse_tier_canonical_names() {
assert_eq!(parse_tier("FAST").unwrap(), Tier::Fast);
assert_eq!(parse_tier("STANDARD").unwrap(), Tier::Standard);
assert_eq!(parse_tier("COMPLEX").unwrap(), Tier::Complex);
assert_eq!(parse_tier("REVIEW").unwrap(), Tier::Review);
}
#[test]
fn parse_tier_is_case_insensitive() {
assert_eq!(parse_tier("fast").unwrap(), Tier::Fast);
assert_eq!(parse_tier("Complex").unwrap(), Tier::Complex);
}
#[test]
fn parse_tier_rejects_unknown() {
let err = parse_tier("BOGUS").unwrap_err().to_string();
assert!(err.contains("invalid tier"), "got: {err}");
assert!(err.contains("BOGUS"), "got: {err}");
}
}