From a0b243d46d763dfa3811f858ef9265a8e1116982 Mon Sep 17 00:00:00 2001 From: erishforG Date: Fri, 19 Jun 2026 09:32:37 +0900 Subject: [PATCH] feat(mcp): Phase 3 auth preflight Refs #294 Co-Authored-By: Claude Opus 4.7 --- src/mcp/mod.rs | 86 +++++++++++++++++++++++++++- tests/mcp/fixtures/stdio_smoke.jsonl | 1 + 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 9b52770..b14a275 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -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 @@ -472,6 +475,36 @@ fn handle_tools_call( } } +fn validate_tool_auth(tool: &ToolDef, ctx: &McpContext) -> Option { + 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::>() + .join(", ") +} + fn mcp_content_envelope(payload: serde_json::Value, is_error: bool) -> serde_json::Value { serde_json::json!({ "content": [{ @@ -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!({ diff --git a/tests/mcp/fixtures/stdio_smoke.jsonl b/tests/mcp/fixtures/stdio_smoke.jsonl index 59283f3..443abf8 100644 --- a/tests/mcp/fixtures/stdio_smoke.jsonl +++ b/tests/mcp/fixtures/stdio_smoke.jsonl @@ -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"}]}