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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ 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

### 中文

- Devin 请求会剥离 Codex/Desktop 的 `mcp__*` 工具,并把 MCP 配置类 `permission_denied` 归为无效请求,而不再按鉴权失败冷却账号

## 0.5.3 - 2026-09-15

### English
Expand Down
60 changes: 58 additions & 2 deletions internal/providers/devin/devin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,8 +498,11 @@ func TestRemoteCatalogHTTPtestSuccessAndFailure(t *testing.T) {

func TestClassifyAuthForbiddenQuotaTrailer(t *testing.T) {
got := Classify(403, "permission_denied")
if got.Kind != accounts.KindAuth || got.Status != 403 {
t.Fatalf("403 classify=%+v", got)
if got.Kind != accounts.KindInvalidRequest {
t.Fatalf("bare permission_denied classify=%+v want invalid_request", got)
}
if got.Status != 400 && got.Status != 403 {
t.Fatalf("bare permission_denied status=%d want 400 or 403", got.Status)
}
got = Classify(401, "")
if got.Kind != accounts.KindAuth {
Expand All @@ -519,6 +522,59 @@ func TestClassifyAuthForbiddenQuotaTrailer(t *testing.T) {
}
}

func TestClassifyMCPConfigPermissionDenied(t *testing.T) {
body := "devin upstream error (permission_denied): Unable to process request due to an MCP configuration issue. (trace ID: 3de0a0f4c7f0e2cd3bd8e001a46749bd)"
got := Classify(403, body)
if got.Kind != accounts.KindInvalidRequest || got.Status != 400 {
t.Fatalf("MCP permission_denied classify=%+v", got)
}
err := classifiedError(403, body)
var providerErr *providers.Error
if !errors.As(err, &providerErr) {
t.Fatalf("classifiedError type=%T", err)
}
if providerErr.Kind != accounts.KindInvalidRequest {
t.Fatalf("kind=%s", providerErr.Kind)
}
if providerErr.Cooldown != 0 {
t.Fatalf("cooldown=%s want 0", providerErr.Cooldown)
}
if providerErr.Failover == nil || *providerErr.Failover {
t.Fatalf("failover=%v want false", providerErr.Failover)
}
}

func TestParseToolsStripsMCPNamespace(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)
}
payload := BuildChatPayload(translate.ChatRequest{
Model: "swe-2",
Messages: []translate.ChatMessage{
{Role: "user", Content: "hi"},
},
Tools: raw,
}, nil)
if len(payload.Tools) != 2 {
t.Fatalf("payload tools=%d want 2", len(payload.Tools))
}
for _, tool := range payload.Tools {
if strings.HasPrefix(strings.ToLower(tool.Name), "mcp__") {
t.Fatalf("mcp tool leaked into payload: %s", tool.Name)
}
}
}

func TestCredentialErrorsDoNotLeakToken(t *testing.T) {
secret := "eyJsuper.secret.token.value"
raw := []byte(`{"session_token":"` + secret + `"}`)
Expand Down
25 changes: 22 additions & 3 deletions internal/providers/devin/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ func Classify(status int, body string) providers.ClassifiedError {
safeBody := redactSecrets(strings.TrimSpace(body))
text := strings.ToLower(safeBody)
switch {
case status == 401 || status == 403 ||
case isDevinMCPConfigDenial(text):
// Codex/Desktop MCP tool dumps make Devin return permission_denied.
// That is a request-shape problem, not a dead session — do not cool
// the account as auth.
return providers.ClassifiedError{
Kind: accounts.KindInvalidRequest,
Status: 400,
Message: firstNonEmpty(safeBody, "devin rejected MCP/hosted tools in the request"),
}
case status == 401 ||
strings.Contains(text, "unauthenticated") ||
strings.Contains(text, "unauthorized") ||
strings.Contains(text, "permission_denied") ||
strings.Contains(text, "session dead"):
return providers.ClassifiedError{
Kind: accounts.KindAuth,
Expand All @@ -57,7 +65,8 @@ func Classify(status int, body string) providers.ClassifiedError {
Status: 429,
Message: firstNonEmpty(safeBody, "rate limited"),
}
case status == 400 || status == 422 ||
case status == 400 || status == 422 || status == 403 ||
strings.Contains(text, "permission_denied") ||
strings.Contains(text, "invalid_argument") ||
strings.Contains(text, "failed_precondition") ||
accounts.IsInvalidRequestText(safeBody) ||
Expand Down Expand Up @@ -116,6 +125,16 @@ func classifiedCooldown(kind string) time.Duration {
}
}

func isDevinMCPConfigDenial(text string) bool {
if text == "" {
return false
}
if strings.Contains(text, "mcp configuration") {
return true
}
return strings.Contains(text, "permission_denied") && strings.Contains(text, "mcp")
}

func firstNonEmptyStatus(status, fallback int) int {
if status >= 400 {
return status
Expand Down
10 changes: 9 additions & 1 deletion internal/providers/devin/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ 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 == "" {
if name == "" || isDevinUnsupportedToolName(name) {
continue
}
desc := firstNonEmpty(t.Function.Description, t.Description)
Expand All @@ -208,6 +208,14 @@ 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__")
}

func extractReasoning(msg translate.ChatMessage) string {
// ChatMessage has no dedicated reasoning field; try content parts with type=thinking.
if parts, ok := msg.Content.([]any); ok {
Expand Down
Loading