From 2676ba46801f197213c1163107c573c9f8e46250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Wed, 16 Sep 2026 01:33:44 +0800 Subject: [PATCH] fix(devin): relay mcp__* tools via aliases for local execution Rename Codex/Desktop mcp__* tool definitions to Devin-safe aliases on the way upstream, then restore the original names on tool_calls so the local client can execute MCP. Keep MCP configuration permission_denied classified as invalid_request. --- CHANGELOG.md | 4 +- internal/providers/devin/chat.go | 34 ++++--- internal/providers/devin/devin_test.go | 107 +++++++++++++++++++--- internal/providers/devin/payload.go | 119 +++++++++++++++++++------ 4 files changed, 212 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b1f28..ecc1b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,11 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English -- Strip Codex/Desktop `mcp__*` tools from Devin requests and treat MCP configuration `permission_denied` as an invalid request instead of cooling the account as auth +- Alias Codex/Desktop `mcp__*` tools for Devin upstream and restore the original names on tool calls so the local client can execute MCP, while still treating MCP configuration `permission_denied` as an invalid request instead of auth cooldown ### 中文 -- Devin 请求会剥离 Codex/Desktop 的 `mcp__*` 工具,并把 MCP 配置类 `permission_denied` 归为无效请求,而不再按鉴权失败冷却账号 +- Devin 会对 Codex/Desktop 的 `mcp__*` 工具做上游别名并在返回的 tool_calls 中还原原名,便于本地客户端执行 MCP;MCP 配置类 `permission_denied` 仍归为无效请求,不再按鉴权失败冷却账号 ## 0.5.3 - 2026-09-15 diff --git a/internal/providers/devin/chat.go b/internal/providers/devin/chat.go index cf51156..f6275e3 100644 --- a/internal/providers/devin/chat.go +++ b/internal/providers/devin/chat.go @@ -19,7 +19,7 @@ func (c *Client) ChatNonStream(ctx context.Context, accountID string, req transl if err != nil { return providers.ChatOutcome{}, err } - httpReq, err := c.buildChatHTTPRequest(ctx, credential, req) + httpReq, originalByAlias, err := c.buildChatHTTPRequest(ctx, credential, req) if err != nil { return providers.ChatOutcome{}, err } @@ -37,7 +37,7 @@ func (c *Client) ChatNonStream(ctx context.Context, accountID string, req transl body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) return providers.ChatOutcome{}, classifiedError(resp.StatusCode, string(body)) } - aggregate, err := aggregateConnectStream(resp.Body) + aggregate, err := aggregateConnectStream(resp.Body, originalByAlias) if err != nil { return providers.ChatOutcome{}, err } @@ -49,7 +49,7 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate if err != nil { return nil, err } - httpReq, err := c.buildChatHTTPRequest(ctx, credential, req) + httpReq, originalByAlias, err := c.buildChatHTTPRequest(ctx, credential, req) if err != nil { return nil, err } @@ -67,10 +67,10 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate resp.Body.Close() return nil, classifiedError(resp.StatusCode, string(body)) } - return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin")) + return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), originalByAlias) } -func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (*http.Request, error) { +func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (*http.Request, map[string]string, error) { payload := BuildChatPayload(req, currentLevels()) proto := BuildGetChatMessageRequest( credential.SessionToken, @@ -88,7 +88,7 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential endpoint := strings.TrimRight(firstNonEmpty(credential.BaseURL, c.serverBase, ServerBase), "/") + PathGetChatMessage httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { - return nil, err + return nil, nil, err } httpReq.Header.Set("Authorization", BasicAuthHeader(credential.SessionToken)) httpReq.Header.Set("Content-Type", ContentTypeConnectProto) @@ -96,7 +96,7 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential httpReq.Header.Set("Accept", "*/*") httpReq.Header.Set("Sentry-Trace", GenerateSentryTrace()) httpReq.Header["User-Agent"] = []string{""} - return httpReq, nil + return httpReq, payload.OriginalByAlias, nil } type aggregateResult struct { @@ -109,7 +109,7 @@ type aggregateResult struct { CompletionTokens int } -func aggregateConnectStream(r io.Reader) (aggregateResult, error) { +func aggregateConnectStream(r io.Reader, originalByAlias map[string]string) (aggregateResult, error) { var out aggregateResult out.FinishReason = "stop" toolAcc := map[int]*ToolCallDelta{} @@ -144,13 +144,16 @@ func aggregateConnectStream(r io.Reader) (aggregateResult, error) { acc, ok := toolAcc[idx] if !ok { cp := delta + if cp.Name != "" { + cp.Name = restoreToolName(cp.Name, originalByAlias) + } toolAcc[idx] = &cp } else { if delta.ID != "" { acc.ID = delta.ID } if delta.Name != "" { - acc.Name = delta.Name + acc.Name = restoreToolName(delta.Name, originalByAlias) } acc.Arguments += delta.Arguments } @@ -222,7 +225,7 @@ func outcomeFromAggregate(aggregate aggregateResult, fallbackModel string) provi return out } -func rewriteConnectStream(upstream *http.Response, model string) (*http.Response, error) { +func rewriteConnectStream(upstream *http.Response, model string, originalByAlias map[string]string) (*http.Response, error) { pr, pw := io.Pipe() go func() { defer upstream.Body.Close() @@ -300,17 +303,22 @@ func rewriteConnectStream(upstream *http.Response, model string) (*http.Response } } for _, delta := range frame.ToolCallDeltas { + name := delta.Name + if name != "" { + name = restoreToolName(name, originalByAlias) + } idx := delta.Index acc, ok := toolAcc[idx] if !ok { cp := delta + cp.Name = name toolAcc[idx] = &cp } else { if delta.ID != "" { acc.ID = delta.ID } - if delta.Name != "" { - acc.Name = delta.Name + if name != "" { + acc.Name = name } acc.Arguments += delta.Arguments } @@ -319,7 +327,7 @@ func rewriteConnectStream(upstream *http.Response, model string) (*http.Response "id": delta.ID, "type": "function", "function": map[string]any{ - "name": delta.Name, + "name": name, "arguments": delta.Arguments, }, } diff --git a/internal/providers/devin/devin_test.go b/internal/providers/devin/devin_test.go index 232c597..0214540 100644 --- a/internal/providers/devin/devin_test.go +++ b/internal/providers/devin/devin_test.go @@ -373,7 +373,7 @@ func TestAggregateConnectStreamMissingEOS(t *testing.T) { textFrame = AppendTag(textFrame, 3, BytesType) textFrame = AppendString(textFrame, "orphan") framed := WrapConnectEnvelope(textFrame) - _, err := aggregateConnectStream(bytes.NewReader(framed)) + _, err := aggregateConnectStream(bytes.NewReader(framed), nil) if err == nil { t.Fatal("expected missing EOS error") } @@ -544,34 +544,119 @@ func TestClassifyMCPConfigPermissionDenied(t *testing.T) { } } -func TestParseToolsStripsMCPNamespace(t *testing.T) { +func TestParseToolsAliasesMCPNamespace(t *testing.T) { raw := json.RawMessage(`[ {"type":"function","function":{"name":"exec_command","description":"run","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"mcp__computer-use__left_click","description":"click","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"MCP__plugin_chrome__click","description":"click","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"web_search","description":"search","parameters":{"type":"object"}}} ]`) - tools := parseTools(raw) - if len(tools) != 2 { - t.Fatalf("tools=%d want 2 (mcp stripped): %+v", len(tools), tools) - } - if tools[0].Name != "exec_command" || tools[1].Name != "web_search" { - t.Fatalf("tools=%+v", tools) - } + historyCalls, _ := json.Marshal([]map[string]any{{ + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "mcp__computer-use__left_click", + "arguments": `{"x":1}`, + }, + }}) payload := BuildChatPayload(translate.ChatRequest{ Model: "swe-2", Messages: []translate.ChatMessage{ {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "", ToolCalls: historyCalls}, + {Role: "tool", ToolCallID: "call_1", Content: "ok"}, }, Tools: raw, }, nil) - if len(payload.Tools) != 2 { - t.Fatalf("payload tools=%d want 2", len(payload.Tools)) + if len(payload.Tools) != 4 { + t.Fatalf("tools=%d want 4: %+v", len(payload.Tools), payload.Tools) } + wantAlias := "mcp_computer_use_left_click" + foundAlias := false for _, tool := range payload.Tools { if strings.HasPrefix(strings.ToLower(tool.Name), "mcp__") { t.Fatalf("mcp tool leaked into payload: %s", tool.Name) } + if tool.Name == wantAlias { + foundAlias = true + } + } + if !foundAlias { + t.Fatalf("missing alias %q in %+v", wantAlias, payload.Tools) + } + if payload.OriginalByAlias[wantAlias] != "mcp__computer-use__left_click" { + t.Fatalf("reverse map=%v", payload.OriginalByAlias) + } + if len(payload.Prompts) < 2 || len(payload.Prompts[1].ToolCalls) != 1 { + t.Fatalf("history prompts=%+v", payload.Prompts) + } + if payload.Prompts[1].ToolCalls[0].Name != wantAlias { + t.Fatalf("history tool call name=%q", payload.Prompts[1].ToolCalls[0].Name) + } + if restoreToolName(wantAlias, payload.OriginalByAlias) != "mcp__computer-use__left_click" { + t.Fatalf("restore failed") + } +} + +func TestChatStreamRestoresMCPToolName(t *testing.T) { + original := "mcp__computer-use__left_click" + alias := "mcp_computer_use_left_click" + toolFrame := buildToolCallDeltaFrame("call_1", alias, `{"x":2}`, 0) + + var buf bytes.Buffer + buf.Write(WrapConnectEnvelope(toolFrame)) + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{}`))) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathGetChatMessage { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + if bytes.Contains(body, []byte(original)) { + t.Errorf("upstream request still contains original mcp name") + } + if !bytes.Contains(body, []byte(alias)) { + t.Errorf("upstream request missing aliased mcp name") + } + w.Header().Set("Content-Type", ContentTypeConnectProto) + _, _ = w.Write(buf.Bytes()) + })) + defer server.Close() + + store := newMemStore() + store.accounts["acc1"] = accounts.Account{ID: "acc1", Provider: "devin", ProviderRegion: "global"} + cred := Credential{SessionToken: FormatSessionToken("eyJabc.def.ghi"), DeviceSeed: "seed", BaseURL: server.URL} + payload, err := cred.Encode() + if err != nil { + t.Fatal(err) + } + store.creds["acc1"] = payload + + tools := json.RawMessage(`[{"type":"function","function":{"name":"mcp__computer-use__left_click","description":"click","parameters":{"type":"object"}}}]`) + client := NewClient(store) + client.SetBases(AppBase, APIBase, server.URL) + resp, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "swe-2-high", + Messages: []translate.ChatMessage{ + {Role: "user", Content: "click"}, + }, + Tools: tools, + }) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + text := string(body) + if !strings.Contains(text, original) { + t.Fatalf("client stream missing restored mcp name: %s", text) + } + if strings.Contains(text, `"`+alias+`"`) { + t.Fatalf("client stream still exposes alias: %s", text) } } diff --git a/internal/providers/devin/payload.go b/internal/providers/devin/payload.go index 6fe88ea..49c63be 100644 --- a/internal/providers/devin/payload.go +++ b/internal/providers/devin/payload.go @@ -1,6 +1,8 @@ package devin import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "strconv" "strings" @@ -8,19 +10,23 @@ import ( "github.com/caigee-cmd/cli2api/internal/translate" ) +const maxDevinToolAliasLen = 64 + // ChatPayload is the normalized Devin Interactions request. type ChatPayload struct { - System string - Prompts []Prompt - Tools []Tool - Temperature *float64 - MaxTokens int - ModelUID string - Effort string - Budget int + System string + Prompts []Prompt + Tools []Tool + Temperature *float64 + MaxTokens int + ModelUID string + Effort string + Budget int + OriginalByAlias map[string]string } func BuildChatPayload(req translate.ChatRequest, catalogLevels map[string][]string) ChatPayload { + aliases := newToolAliasMaps() var systemParts []string prompts := make([]Prompt, 0, len(req.Messages)) for _, msg := range req.Messages { @@ -38,7 +44,7 @@ func BuildChatPayload(req translate.ChatRequest, catalogLevels map[string][]stri text, images := splitContent(msg.Content) thinking := extractReasoning(msg) p := Prompt{Source: 2, Content: text, Images: images, Thinking: thinking} - p.ToolCalls = parseToolCalls(msg.ToolCalls) + p.ToolCalls = parseToolCalls(msg.ToolCalls, aliases) prompts = append(prompts, p) case "tool": text := translate.ContentToString(msg.Content) @@ -59,14 +65,15 @@ func BuildChatPayload(req translate.ChatRequest, catalogLevels map[string][]stri modelUID := ResolveChatModelUID(req.Model, effort, budget, catalogLevels) return ChatPayload{ - System: system, - Prompts: prompts, - Tools: parseTools(req.Tools), - Temperature: temp, - MaxTokens: maxTokens, - ModelUID: modelUID, - Effort: effort, - Budget: budget, + System: system, + Prompts: prompts, + Tools: parseTools(req.Tools, aliases), + Temperature: temp, + MaxTokens: maxTokens, + ModelUID: modelUID, + Effort: effort, + Budget: budget, + OriginalByAlias: aliases.originalByAlias, } } @@ -145,7 +152,7 @@ func decodeDataURL(raw string) Image { return Image{Base64Data: data, MimeType: mime} } -func parseToolCalls(raw json.RawMessage) []ToolCall { +func parseToolCalls(raw json.RawMessage, aliases *toolAliasMaps) []ToolCall { if len(raw) == 0 { return nil } @@ -169,12 +176,15 @@ func parseToolCalls(raw json.RawMessage) []ToolCall { if name == "" && args == "" && c.ID == "" { continue } + if name != "" { + name = aliases.alias(name) + } out = append(out, ToolCall{ID: c.ID, Name: name, Arguments: args}) } return out } -func parseTools(raw json.RawMessage) []Tool { +func parseTools(raw json.RawMessage, aliases *toolAliasMaps) []Tool { if len(raw) == 0 { return nil } @@ -195,9 +205,10 @@ func parseTools(raw json.RawMessage) []Tool { out := make([]Tool, 0, len(tools)) for _, t := range tools { name := firstNonEmpty(t.Function.Name, t.Name) - if name == "" || isDevinUnsupportedToolName(name) { + if name == "" { continue } + name = aliases.alias(name) desc := firstNonEmpty(t.Function.Description, t.Description) params := t.Function.Parameters if len(params) == 0 { @@ -208,12 +219,68 @@ func parseTools(raw json.RawMessage) []Tool { return out } -// isDevinUnsupportedToolName drops Codex/Desktop MCP and similar hosted tools. -// Devin cannot host those namespaces and rejects the whole request with an MCP -// configuration permission_denied trailer if they are forwarded. -func isDevinUnsupportedToolName(name string) bool { - lower := strings.ToLower(strings.TrimSpace(name)) - return strings.HasPrefix(lower, "mcp__") +type toolAliasMaps struct { + aliasByOriginal map[string]string + originalByAlias map[string]string +} + +func newToolAliasMaps() *toolAliasMaps { + return &toolAliasMaps{ + aliasByOriginal: map[string]string{}, + originalByAlias: map[string]string{}, + } +} + +func needsDevinToolAlias(name string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(name)), "mcp__") +} + +func (m *toolAliasMaps) alias(original string) string { + original = strings.TrimSpace(original) + if original == "" || m == nil { + return original + } + if !needsDevinToolAlias(original) { + return original + } + if existing, ok := m.aliasByOriginal[original]; ok { + return existing + } + alias := makeDevinToolAlias(original) + for { + if prev, ok := m.originalByAlias[alias]; !ok || prev == original { + break + } + alias = makeDevinToolAlias(original + "#" + alias) + } + m.aliasByOriginal[original] = alias + m.originalByAlias[alias] = original + return alias +} + +func makeDevinToolAlias(original string) string { + alias := strings.ReplaceAll(original, "__", "_") + alias = strings.ReplaceAll(alias, "-", "_") + alias = strings.TrimSpace(alias) + if alias == "" { + alias = "mcp_tool" + } + if len(alias) <= maxDevinToolAliasLen && !strings.Contains(alias, "__") { + return alias + } + sum := sha256.Sum256([]byte(original)) + return "mcp_" + hex.EncodeToString(sum[:8]) +} + +func restoreToolName(name string, originalByAlias map[string]string) string { + name = strings.TrimSpace(name) + if name == "" || len(originalByAlias) == 0 { + return name + } + if original, ok := originalByAlias[name]; ok && original != "" { + return original + } + return name } func extractReasoning(msg translate.ChatMessage) string {