classifyHTTPStatusKind turns an HTTP error response into a structured envelope, using the dispatcher's known resource context to emit the right error code and an actionable hint (TASK-1078 / TASK-1079). Parameters: - ctx, cmdKey, lookup — same as classifyHTTPStatus. - route — the URL path the disp
( ctx context.Context, cmdKey, route string, status int, body []byte, lookup WorkspaceLister, kind ResourceKind, refOrSlug string, )
| 751 | // not found in workspace foo" knows exactly what to fix. May be |
| 752 | // empty. |
| 753 | func classifyHTTPStatusKind( |
| 754 | ctx context.Context, |
| 755 | cmdKey, route string, |
| 756 | status int, |
| 757 | body []byte, |
| 758 | lookup WorkspaceLister, |
| 759 | kind ResourceKind, |
| 760 | refOrSlug string, |
| 761 | ) *mcp.CallToolResult { |
| 762 | bodyText := strings.TrimSpace(string(body)) |
| 763 | bodyMessage := extractUpstreamMessage(bodyText) |
| 764 | if bodyText == "" { |
| 765 | bodyText = http.StatusText(status) |
| 766 | } |
| 767 | |
| 768 | switch status { |
| 769 | case http.StatusUnauthorized: |
| 770 | return NewErrorResult(ErrorPayload{ |
| 771 | Code: ErrAuthRequired, |
| 772 | Message: "Authentication required.", |
| 773 | Hint: authHintFor(bodyMessage, route), |
| 774 | }) |
| 775 | case http.StatusForbidden: |
| 776 | // TASK-788: when the handler emitted a plan_limit_exceeded structured body, |
| 777 | // surface it with the dedicated code + details so MCP-driven agents can |
| 778 | // present an upgrade-to-Pro signal instead of a generic permission error. |
| 779 | // Whitelisted through allowedStructuredErrorCodes (same pattern as |
| 780 | // open_children on 409) so unknown 403 codes still collapse to |
| 781 | // ErrPermissionDenied. |
| 782 | upstream403 := extractUpstreamErrorEnvelope(bodyText) |
| 783 | if _, allowed := allowedStructuredErrorCodes[upstream403.Code]; allowed { |
| 784 | return NewErrorResult(ErrorPayload{ |
| 785 | Code: ErrorCode(upstream403.Code), |
| 786 | Message: upstream403.Message, |
| 787 | Hint: planLimitHintFor(upstream403.Message, route), |
| 788 | Details: upstream403.Details, |
| 789 | }) |
| 790 | } |
| 791 | return NewErrorResult(ErrorPayload{ |
| 792 | Code: ErrPermissionDenied, |
| 793 | Message: "Permission denied for this operation.", |
| 794 | Hint: permissionHintFor(bodyMessage, route), |
| 795 | }) |
| 796 | case http.StatusNotFound: |
| 797 | return classify404(ctx, cmdKey, route, bodyText, bodyMessage, lookup, kind, refOrSlug) |
| 798 | case http.StatusConflict: |
| 799 | // IDEA-1494 R2/R4 P2: preserve the upstream `code` + `details` |
| 800 | // when the handler emitted a structured rejection — but ONLY |
| 801 | // for codes that appear in the shared allow-list |
| 802 | // (allowedStructuredErrorCodes, defined below). The stdio |
| 803 | // classifier already gates on the same set; routing both |
| 804 | // transports through the same whitelist keeps the |
| 805 | // ErrorCode enum's closed contract honest (round-4 P2: |
| 806 | // HTTP was forwarding any non-"conflict" code, diverging |
| 807 | // from stdio). |
| 808 | // |
| 809 | // New structured codes get added to allowedStructuredErrorCodes |
| 810 | // in one place; both transports adopt them in lockstep. |