Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 21 additions & 13 deletions internal/providers/devin/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -88,15 +88,15 @@ 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)
httpReq.Header.Set("Connect-Protocol-Version", ConnectProtocolVersion)
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 {
Expand All @@ -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{}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
},
}
Expand Down
107 changes: 96 additions & 11 deletions internal/providers/devin/devin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading