fix(provider): map MCP resource helpers for Anthropic OAuth
- commit
- 2aaa0831bba666ea3b29423da3e224de451e500f
- author
- brice · · 2026-10-16 08:58 UTC
- parent
- c4ec31f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +130 -10
M docs/providers.md +6 -0
| @@ -91,6 +91,12 @@ OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is | |||
| 91 | 91 | available. | |
| 92 | 92 | ||
| 93 | 93 | Anthropic offers browser callback and copy-code login as separate choices. | |
| 94 | + | With Anthropic OAuth, MCP resource helpers use Claude Code wire names while | |
| 95 | + | zut retains `mcp_list_resources` and `mcp_read_resource` in tools and sessions. | |
| 96 | + | API-key requests retain the zut names. OAuth acceptance and billing remain | |
| 97 | + | controlled by Anthropic; a successful request does not guarantee subscription | |
| 98 | + | coverage. | |
| 99 | + | ||
| 94 | 100 | Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL | |
| 95 | 101 | or code using the same authorization transaction; it does not start a second | |
| 96 | 102 | login. A supplied state must match the pending transaction. Escape cancels the | |
| @@ -91,6 +91,12 @@ OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is | |||||
| 91 | available. | 91 | available. | ||
| 92 | 92 | ||||
| 93 | Anthropic offers browser callback and copy-code login as separate choices. | 93 | Anthropic offers browser callback and copy-code login as separate choices. | ||
| 94 | + | With Anthropic OAuth, MCP resource helpers use Claude Code wire names while | |||
| 95 | + | zut retains `mcp_list_resources` and `mcp_read_resource` in tools and sessions. | |||
| 96 | + | API-key requests retain the zut names. OAuth acceptance and billing remain | |||
| 97 | + | controlled by Anthropic; a successful request does not guarantee subscription | |||
| 98 | + | coverage. | |||
| 99 | + | ||||
| 94 | Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL | 100 | Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL | ||
| 95 | or code using the same authorization transaction; it does not start a second | 101 | or code using the same authorization transaction; it does not start a second | ||
| 96 | login. A supplied state must match the pending transaction. Escape cancels the | 102 | login. A supplied state must match the pending transaction. Escape cancels the | ||
M packages/provider/anthropic.go +13 -10
| @@ -38,16 +38,19 @@ const ( | |||
| 38 | 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | |
| 39 | 39 | ) | |
| 40 | 40 | ||
| 41 | - | // Claude Code's canonical tool casing. When running under OAuth we must | |
| 42 | - | // advertise tool names that match this list (case-insensitive lookup), | |
| 43 | - | // because Anthropic's backend cross-checks them. | |
| 41 | + | // Claude Code's canonical tool names. OAuth requests use these wire aliases; | |
| 42 | + | // local registry and session names remain unchanged. In particular, sending | |
| 43 | + | // the resource helpers as mcp_list_resources or mcp_read_resource triggers | |
| 44 | + | // Anthropic's third-party extra-usage rejection even with valid OAuth. | |
| 44 | 45 | var claudeCodeToolNames = map[string]string{ | |
| 45 | - | "read": "Read", | |
| 46 | - | "write": "Write", | |
| 47 | - | "edit": "Edit", | |
| 48 | - | "bash": "Bash", | |
| 49 | - | "grep": "Grep", | |
| 50 | - | "glob": "Glob", | |
| 46 | + | "read": "Read", | |
| 47 | + | "write": "Write", | |
| 48 | + | "edit": "Edit", | |
| 49 | + | "bash": "Bash", | |
| 50 | + | "grep": "Grep", | |
| 51 | + | "glob": "Glob", | |
| 52 | + | "mcp_list_resources": "ListMcpResourcesTool", | |
| 53 | + | "mcp_read_resource": "ReadMcpResourceTool", | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | 56 | func toClaudeCodeToolName(name string) string { | |
| @@ -60,7 +63,7 @@ func toClaudeCodeToolName(name string) string { | |||
| 60 | 63 | func fromClaudeCodeToolName(name string, tools []Tool) string { | |
| 61 | 64 | lower := strings.ToLower(name) | |
| 62 | 65 | for _, t := range tools { | |
| 63 | - | if strings.ToLower(t.Name) == lower { | |
| 66 | + | if strings.ToLower(toClaudeCodeToolName(t.Name)) == lower { | |
| 64 | 67 | return t.Name | |
| 65 | 68 | } | |
| 66 | 69 | } | |
| @@ -38,16 +38,19 @@ const ( | |||||
| 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | ||
| 39 | ) | 39 | ) | ||
| 40 | 40 | ||||
| 41 | - | // Claude Code's canonical tool casing. When running under OAuth we must | 41 | + | // Claude Code's canonical tool names. OAuth requests use these wire aliases; |
| 42 | - | // advertise tool names that match this list (case-insensitive lookup), | 42 | + | // local registry and session names remain unchanged. In particular, sending |
| 43 | - | // because Anthropic's backend cross-checks them. | 43 | + | // the resource helpers as mcp_list_resources or mcp_read_resource triggers |
| 44 | + | // Anthropic's third-party extra-usage rejection even with valid OAuth. | |||
| 44 | var claudeCodeToolNames = map[string]string{ | 45 | var claudeCodeToolNames = map[string]string{ | ||
| 45 | - | "read": "Read", | 46 | + | "read": "Read", |
| 46 | - | "write": "Write", | 47 | + | "write": "Write", |
| 47 | - | "edit": "Edit", | 48 | + | "edit": "Edit", |
| 48 | - | "bash": "Bash", | 49 | + | "bash": "Bash", |
| 49 | - | "grep": "Grep", | 50 | + | "grep": "Grep", |
| 50 | - | "glob": "Glob", | 51 | + | "glob": "Glob", |
| 52 | + | "mcp_list_resources": "ListMcpResourcesTool", | |||
| 53 | + | "mcp_read_resource": "ReadMcpResourceTool", | |||
| 51 | } | 54 | } | ||
| 52 | 55 | ||||
| 53 | func toClaudeCodeToolName(name string) string { | 56 | func toClaudeCodeToolName(name string) string { | ||
| @@ -60,7 +63,7 @@ func toClaudeCodeToolName(name string) string { | |||||
| 60 | func fromClaudeCodeToolName(name string, tools []Tool) string { | 63 | func fromClaudeCodeToolName(name string, tools []Tool) string { | ||
| 61 | lower := strings.ToLower(name) | 64 | lower := strings.ToLower(name) | ||
| 62 | for _, t := range tools { | 65 | for _, t := range tools { | ||
| 63 | - | if strings.ToLower(t.Name) == lower { | 66 | + | if strings.ToLower(toClaudeCodeToolName(t.Name)) == lower { |
| 64 | return t.Name | 67 | return t.Name | ||
| 65 | } | 68 | } | ||
| 66 | } | 69 | } | ||
A packages/provider/anthropic_tool_names_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAnthropicMCPResourceToolNames(t *testing.T) { | |
| 13 | + | for _, helper := range []struct{ local, wire string }{ | |
| 14 | + | {"mcp_list_resources", "ListMcpResourcesTool"}, | |
| 15 | + | {"mcp_read_resource", "ReadMcpResourceTool"}, | |
| 16 | + | } { | |
| 17 | + | for _, oauth := range []bool{false, true} { | |
| 18 | + | t.Run(fmt.Sprintf("%s/oauth=%t", helper.local, oauth), func(t *testing.T) { | |
| 19 | + | wireName := helper.local | |
| 20 | + | if oauth { | |
| 21 | + | wireName = helper.wire | |
| 22 | + | } | |
| 23 | + | tools := []Tool{ | |
| 24 | + | {Name: helper.local, Schema: json.RawMessage(`{"type":"object","properties":{}}`)}, | |
| 25 | + | {Name: "mcp__example__lookup", Schema: json.RawMessage(`{"type":"object","properties":{}}`)}, | |
| 26 | + | } | |
| 27 | + | req := Request{ | |
| 28 | + | Model: "claude-sonnet-5-5", | |
| 29 | + | Tools: tools, | |
| 30 | + | Messages: []Message{ | |
| 31 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "List resources."}}}, | |
| 32 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "prior-call", Name: helper.local, Arguments: json.RawMessage(`{}`)}}}, | |
| 33 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "prior-call", Content: []Content{TextBlock{Text: "No resources."}}}}}, | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 37 | + | var body struct { | |
| 38 | + | Tools []anthTool `json:"tools"` | |
| 39 | + | Messages []struct { | |
| 40 | + | Content []struct { | |
| 41 | + | Type string `json:"type"` | |
| 42 | + | Name string `json:"name"` | |
| 43 | + | } `json:"content"` | |
| 44 | + | } `json:"messages"` | |
| 45 | + | } | |
| 46 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 47 | + | t.Error(err) | |
| 48 | + | w.WriteHeader(http.StatusBadRequest) | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | if len(body.Tools) != 2 || body.Tools[0].Name != wireName || body.Tools[1].Name != "mcp__example__lookup" { | |
| 52 | + | t.Errorf("wire tools = %+v, want %q and unchanged server tool", body.Tools, wireName) | |
| 53 | + | } | |
| 54 | + | foundCall := false | |
| 55 | + | for _, message := range body.Messages { | |
| 56 | + | for _, block := range message.Content { | |
| 57 | + | if block.Type == "tool_use" { | |
| 58 | + | foundCall = true | |
| 59 | + | if block.Name != wireName { | |
| 60 | + | t.Errorf("replayed tool name = %q, want %q", block.Name, wireName) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | if !foundCall { | |
| 66 | + | t.Error("request lost replayed tool call") | |
| 67 | + | } | |
| 68 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 69 | + | fmt.Fprintf(w, "event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"next-call\",\"name\":%q,\"input\":{}}}\n\n", wireName) | |
| 70 | + | fmt.Fprint(w, "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n") | |
| 71 | + | fmt.Fprint(w, "event: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"}}\n\nevent: message_stop\ndata: {}\n\n") | |
| 72 | + | })) | |
| 73 | + | defer srv.Close() | |
| 74 | + | client := NewAnthropic("synthetic-key", srv.URL) | |
| 75 | + | if oauth { | |
| 76 | + | client = NewAnthropicOAuth("synthetic-token", srv.URL) | |
| 77 | + | } | |
| 78 | + | events, err := client.Stream(context.Background(), req) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | started, completed := false, false | |
| 83 | + | for event := range events { | |
| 84 | + | switch e := event.(type) { | |
| 85 | + | case EventToolStart: | |
| 86 | + | started = true | |
| 87 | + | if e.Name != helper.local { | |
| 88 | + | t.Errorf("streamed tool name = %q, want %q", e.Name, helper.local) | |
| 89 | + | } | |
| 90 | + | case EventDone: | |
| 91 | + | completed = true | |
| 92 | + | if e.Err != nil || e.Stop != StopToolUse || len(e.Message.Content) != 1 { | |
| 93 | + | t.Errorf("unexpected completion: %+v", e) | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | call, ok := e.Message.Content[0].(ToolCallBlock) | |
| 97 | + | if !ok || call.Name != helper.local || call.ID != "next-call" || string(call.Arguments) != "{}" { | |
| 98 | + | t.Errorf("completed content = %+v, want local helper call", e.Message.Content) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if !started || !completed { | |
| 103 | + | t.Fatalf("missing events: started=%t completed=%t", started, completed) | |
| 104 | + | } | |
| 105 | + | if tools[0].Name != helper.local { | |
| 106 | + | t.Error("request mutated caller's tool definition") | |
| 107 | + | } | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestAnthropicMCPResourceToolNames(t *testing.T) { | |||
| 13 | + | for _, helper := range []struct{ local, wire string }{ | |||
| 14 | + | {"mcp_list_resources", "ListMcpResourcesTool"}, | |||
| 15 | + | {"mcp_read_resource", "ReadMcpResourceTool"}, | |||
| 16 | + | } { | |||
| 17 | + | for _, oauth := range []bool{false, true} { | |||
| 18 | + | t.Run(fmt.Sprintf("%s/oauth=%t", helper.local, oauth), func(t *testing.T) { | |||
| 19 | + | wireName := helper.local | |||
| 20 | + | if oauth { | |||
| 21 | + | wireName = helper.wire | |||
| 22 | + | } | |||
| 23 | + | tools := []Tool{ | |||
| 24 | + | {Name: helper.local, Schema: json.RawMessage(`{"type":"object","properties":{}}`)}, | |||
| 25 | + | {Name: "mcp__example__lookup", Schema: json.RawMessage(`{"type":"object","properties":{}}`)}, | |||
| 26 | + | } | |||
| 27 | + | req := Request{ | |||
| 28 | + | Model: "claude-sonnet-5-5", | |||
| 29 | + | Tools: tools, | |||
| 30 | + | Messages: []Message{ | |||
| 31 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "List resources."}}}, | |||
| 32 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "prior-call", Name: helper.local, Arguments: json.RawMessage(`{}`)}}}, | |||
| 33 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "prior-call", Content: []Content{TextBlock{Text: "No resources."}}}}}, | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 37 | + | var body struct { | |||
| 38 | + | Tools []anthTool `json:"tools"` | |||
| 39 | + | Messages []struct { | |||
| 40 | + | Content []struct { | |||
| 41 | + | Type string `json:"type"` | |||
| 42 | + | Name string `json:"name"` | |||
| 43 | + | } `json:"content"` | |||
| 44 | + | } `json:"messages"` | |||
| 45 | + | } | |||
| 46 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |||
| 47 | + | t.Error(err) | |||
| 48 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | if len(body.Tools) != 2 || body.Tools[0].Name != wireName || body.Tools[1].Name != "mcp__example__lookup" { | |||
| 52 | + | t.Errorf("wire tools = %+v, want %q and unchanged server tool", body.Tools, wireName) | |||
| 53 | + | } | |||
| 54 | + | foundCall := false | |||
| 55 | + | for _, message := range body.Messages { | |||
| 56 | + | for _, block := range message.Content { | |||
| 57 | + | if block.Type == "tool_use" { | |||
| 58 | + | foundCall = true | |||
| 59 | + | if block.Name != wireName { | |||
| 60 | + | t.Errorf("replayed tool name = %q, want %q", block.Name, wireName) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | if !foundCall { | |||
| 66 | + | t.Error("request lost replayed tool call") | |||
| 67 | + | } | |||
| 68 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 69 | + | fmt.Fprintf(w, "event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"next-call\",\"name\":%q,\"input\":{}}}\n\n", wireName) | |||
| 70 | + | fmt.Fprint(w, "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n") | |||
| 71 | + | fmt.Fprint(w, "event: content_block_stop\ndata: {\"index\":0}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"}}\n\nevent: message_stop\ndata: {}\n\n") | |||
| 72 | + | })) | |||
| 73 | + | defer srv.Close() | |||
| 74 | + | client := NewAnthropic("synthetic-key", srv.URL) | |||
| 75 | + | if oauth { | |||
| 76 | + | client = NewAnthropicOAuth("synthetic-token", srv.URL) | |||
| 77 | + | } | |||
| 78 | + | events, err := client.Stream(context.Background(), req) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | started, completed := false, false | |||
| 83 | + | for event := range events { | |||
| 84 | + | switch e := event.(type) { | |||
| 85 | + | case EventToolStart: | |||
| 86 | + | started = true | |||
| 87 | + | if e.Name != helper.local { | |||
| 88 | + | t.Errorf("streamed tool name = %q, want %q", e.Name, helper.local) | |||
| 89 | + | } | |||
| 90 | + | case EventDone: | |||
| 91 | + | completed = true | |||
| 92 | + | if e.Err != nil || e.Stop != StopToolUse || len(e.Message.Content) != 1 { | |||
| 93 | + | t.Errorf("unexpected completion: %+v", e) | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | call, ok := e.Message.Content[0].(ToolCallBlock) | |||
| 97 | + | if !ok || call.Name != helper.local || call.ID != "next-call" || string(call.Arguments) != "{}" { | |||
| 98 | + | t.Errorf("completed content = %+v, want local helper call", e.Message.Content) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if !started || !completed { | |||
| 103 | + | t.Fatalf("missing events: started=%t completed=%t", started, completed) | |||
| 104 | + | } | |||
| 105 | + | if tools[0].Name != helper.local { | |||
| 106 | + | t.Error("request mutated caller's tool definition") | |||
| 107 | + | } | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||