Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,16 @@ fn handle_tools_call(
"tools/call params.arguments must be an object when present",
);
}
if tool_by_name(name).is_none() {
let Some(tool) = tool_by_name(name) else {
return json_rpc_error(
id,
-32602,
"Invalid params",
format!("unknown MCP tool '{name}'"),
);
};
if let Some(auth_error) = validate_tool_auth(tool, ctx) {
return json_rpc_result(id, mcp_content_envelope(auth_error, true));
}

let arguments = params
Expand All @@ -472,6 +475,36 @@ fn handle_tools_call(
}
}

fn validate_tool_auth(tool: &ToolDef, ctx: &McpContext) -> Option<serde_json::Value> {
if tool.requires_github && ctx.github_token.is_none() {
return Some(serde_json::json!({
"error": {
"code": "AUTH_REQUIRED",
"message": format!("Tool '{}' requires a delegated GitHub token.", tool.name),
"detail": format!(
"Pass a session token with {} access.",
github_scope_detail(tool.github_scopes)
),
"tool": tool.name,
}
}));
}

None
}

fn github_scope_detail(scopes: &[GithubScope]) -> String {
if scopes.is_empty() {
return "GitHub".to_string();
}

scopes
.iter()
.map(|scope| scope.as_str())
.collect::<Vec<_>>()
.join(", ")
}

fn mcp_content_envelope(payload: serde_json::Value, is_error: bool) -> serde_json::Value {
serde_json::json!({
"content": [{
Expand Down Expand Up @@ -759,6 +792,57 @@ mod tests {
.is_some_and(|text| text.contains("\"tool\":\"worktree_status\"")));
}

#[test]
fn tools_call_rejects_required_github_tool_without_token() {
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": "auth",
"method": "tools/call",
"params": {
"name": "ci_status",
"arguments": {"ticket": "ABC-123"}
}
});

let response = dispatch_json_rpc(request).expect("tools/call should produce a response");
let text = response["result"]["content"][0]["text"]
.as_str()
.expect("MCP error text should be present");

assert_eq!(response["id"], "auth");
assert_eq!(response["result"]["isError"], true);
assert!(text.contains("\"code\":\"AUTH_REQUIRED\""));
assert!(text.contains("\"tool\":\"ci_status\""));
assert!(text.contains("checks:read"));
}

#[test]
fn tools_call_allows_required_github_tool_with_token() {
let ctx = McpContext::from_cwd(false)
.expect("should build context")
.with_github_token("ghp_test");
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": "auth-ok",
"method": "tools/call",
"params": {
"name": "ci_status",
"arguments": {"ticket": "ABC-123"}
}
});

let response = dispatch_json_rpc_with_context(request, &ctx)
.expect("tools/call should produce a response");
let text = response["result"]["content"][0]["text"]
.as_str()
.expect("MCP error text should be present");

assert_eq!(response["id"], "auth-ok");
assert_eq!(response["result"]["isError"], true);
assert!(!text.contains("AUTH_REQUIRED"));
assert!(text.contains("\"tool\":\"ci_status\""));
}

#[test]
fn notifications_do_not_produce_responses() {
let request = serde_json::json!({
Expand Down
1 change: 1 addition & 0 deletions tests/mcp/fixtures/stdio_smoke.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{"name":"initialized-notification","request":{"jsonrpc":"2.0","method":"notifications/initialized","params":{}},"no_response":true}
{"name":"tools-list","request":{"jsonrpc":"2.0","id":"tools","method":"tools/list"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"tools"},{"pointer":"/result/tools","min_len":10},{"pointer":"/result/tools","contains_tool":"worktree_list"},{"pointer":"/result/tools","contains_tool":"worktree_ship"},{"pointer":"/result/tools","contains_tool":"smartlog"},{"pointer":"/result/tools","contains_tool":"ci_status"},{"pointer":"/result/tools","contains_tool":"reviews"}]}
{"name":"tools-call-unwired","request":{"jsonrpc":"2.0","id":"call","method":"tools/call","params":{"name":"worktree_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"call"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"worktree_status"}]}
{"name":"tools-call-github-auth-required","request":{"jsonrpc":"2.0","id":"auth","method":"tools/call","params":{"name":"ci_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"checks:read"}]}
{"name":"tools-call-unknown-tool","request":{"jsonrpc":"2.0","id":"bad-tool","method":"tools/call","params":{"name":"nope","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-tool"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"}]}
{"name":"tools-call-missing-name","request":{"jsonrpc":"2.0","id":"missing-name","method":"tools/call","params":{"arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"missing-name"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"},{"pointer":"/error/data","contains_text":"params.name"}]}
{"name":"tools-call-non-object-arguments","request":{"jsonrpc":"2.0","id":"bad-arguments","method":"tools/call","params":{"name":"worktree_status","arguments":"ticket=ABC-123"}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-arguments"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"},{"pointer":"/error/data","contains_text":"arguments"}]}
Loading