detached 2aaa083

fix(provider): map MCP resource helpers for Anthropic OAuth

commit
2aaa0831bba666ea3b29423da3e224de451e500f
author
brice · · 2026-10-16 08:58 UTC
parent
c4ec31f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
9191 available.
9292
9393 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+
94100 Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL
95101 or code using the same authorization transaction; it does not start a second
96102 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 URL100 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 second101 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 the102 login. A supplied state must match the pending transaction. Escape cancels the
M packages/provider/anthropic.go +13 -10
@@ -38,16 +38,19 @@ const (
3838 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
3939 )
4040
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.
4445 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",
5154 }
5255
5356 func toClaudeCodeToolName(name string) string {
@@ -60,7 +63,7 @@ func toClaudeCodeToolName(name string) string {
6063 func fromClaudeCodeToolName(name string, tools []Tool) string {
6164 lower := strings.ToLower(name)
6265 for _, t := range tools {
63- if strings.ToLower(t.Name) == lower {
66+ if strings.ToLower(toClaudeCodeToolName(t.Name)) == lower {
6467 return t.Name
6568 }
6669 }
@@ -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 must41+// 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.Name67 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close