diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4b534..f7b1f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/providers/devin/devin_test.go b/internal/providers/devin/devin_test.go index 2546c3f..232c597 100644 --- a/internal/providers/devin/devin_test.go +++ b/internal/providers/devin/devin_test.go @@ -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 { @@ -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 + `"}`) diff --git a/internal/providers/devin/errors.go b/internal/providers/devin/errors.go index 4e269d2..5b57804 100644 --- a/internal/providers/devin/errors.go +++ b/internal/providers/devin/errors.go @@ -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, @@ -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) || @@ -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 diff --git a/internal/providers/devin/payload.go b/internal/providers/devin/payload.go index 5f99500..6fe88ea 100644 --- a/internal/providers/devin/payload.go +++ b/internal/providers/devin/payload.go @@ -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) @@ -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 {