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
89 changes: 89 additions & 0 deletions pkg/mcp/classification_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package mcp

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)

// WriteClassificationError writes an HTTP 400 response with a JSON-RPC error
// body for an mcp.ClassifyRevision failure. Use this with http.ResponseWriter
// in the streamable HTTP proxy.
func WriteClassificationError(w http.ResponseWriter, requestID any, err error) {
body := classificationErrorBody(requestID, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
//nolint:gosec // G104: writing a JSON-RPC error response to an HTTP client
_, _ = w.Write(body)
}

// ClassificationErrorResponse constructs an *http.Response with HTTP 400 and
// a JSON-RPC error body for an mcp.ClassifyRevision failure. Use this in
// httputil.ReverseProxy.ModifyResponse/RoundTrip (transparent proxy) where no
// http.ResponseWriter is available.
//
// req is attached as resp.Request, matching session.NotFoundResponse: a
// RoundTripper-produced *http.Response is generally expected to carry the
// request it answers, and httputil.ReverseProxy relies on that field.
func ClassificationErrorResponse(req *http.Request, requestID any, err error) *http.Response {
body := classificationErrorBody(requestID, err)
hdr := make(http.Header)
hdr.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusBadRequest,
Status: fmt.Sprintf("%d %s", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: hdr,
ContentLength: int64(len(body)),
Body: io.NopCloser(bytes.NewReader(body)),
Request: req,
}
}

// classificationErrorBody renders an mcp.ClassifyRevision error as a JSON-RPC
// error body, modeled on session.NotFoundBody: the body is marshaled first
// (with a hand-crafted fallback on marshal failure) so callers only write
// headers/status once a valid body is ready.
//
// It uses the error's Code(), Error() message, and Data() (when non-empty) if
// the error implements CodedError, falling back to the standard JSON-RPC
// Invalid Params code otherwise -- a fallback that is currently unreachable,
// since every error ClassifyRevision returns implements CodedError.
func classificationErrorBody(requestID any, err error) []byte {
code := CodeInvalidParams
var coded CodedError
var data map[string]any
if errors.As(err, &coded) {
code = coded.Code()
data = coded.Data()
}

errBody := map[string]any{
"code": code,
"message": err.Error(),
}
if len(data) > 0 {
errBody["data"] = data
}
resp := map[string]any{
"jsonrpc": "2.0",
"error": errBody,
"id": requestID,
}

body, marshalErr := json.Marshal(resp)
if marshalErr != nil {
// This should never happen with simple map types, but return a
// hand-crafted fallback to guarantee a valid JSON-RPC error.
return []byte(`{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":null}`)
}
return body
}
165 changes: 165 additions & 0 deletions pkg/mcp/classification_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package mcp

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)

func TestClassificationError(t *testing.T) {
t.Parallel()

tests := []struct {
name string
requestID any
err error
wantCode int64
wantData bool
}{
{
name: "header mismatch",
requestID: "req-1",
err: &HeaderMismatchError{Header: "2026-07-28", Body: "2025-11-25"},
wantCode: CodeHeaderMismatch,
wantData: true,
},
{
name: "unsupported version",
requestID: float64(42),
err: &UnsupportedVersionError{Requested: "1999-01-01", Supported: []string{MCPVersionModern}},
wantCode: CodeUnsupportedProtocolVersion,
wantData: true,
},
{
name: "missing client capability",
requestID: "req-3",
err: &MissingClientCapabilityError{RequiredCapabilities: map[string]any{}},
wantCode: CodeMissingClientCapability,
wantData: true,
},
{
name: "missing modern metadata",
requestID: "req-4",
err: &MissingModernMetadataError{},
wantCode: CodeInvalidParams,
wantData: false, // Data() returns an empty (non-nil) map, so len(data) == 0
},
{
name: "nil request id",
requestID: nil,
err: &HeaderMismatchError{Header: "a", Body: "b"},
wantCode: CodeHeaderMismatch,
wantData: true,
},
{
name: "plain non-coded error falls back to invalid params",
requestID: "req-6",
err: errors.New("boom"),
wantCode: CodeInvalidParams,
wantData: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

rec := httptest.NewRecorder()
WriteClassificationError(rec, tt.requestID, tt.err)

req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
resp := ClassificationErrorResponse(req, tt.requestID, tt.err)

if rec.Code != http.StatusBadRequest {
t.Fatalf("WriteClassificationError: status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("ClassificationErrorResponse: status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("WriteClassificationError: Content-Type = %q, want application/json", ct)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("ClassificationErrorResponse: Content-Type = %q, want application/json", ct)
}
if resp.Request != req {
t.Fatalf("ClassificationErrorResponse: Request not attached")
}
if resp.Body == nil {
t.Fatalf("ClassificationErrorResponse: Body is nil")
}

respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading response body: %v", err)
}
if resp.ContentLength != int64(len(respBody)) {
t.Fatalf("ContentLength = %d, want %d", resp.ContentLength, len(respBody))
}

wireBody := rec.Body.Bytes()
if string(wireBody) != string(respBody) {
t.Fatalf("WriteClassificationError and ClassificationErrorResponse bodies differ:\n%s\nvs\n%s", wireBody, respBody)
}

var decoded struct {
JSONRPC string `json:"jsonrpc"`
Error struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data map[string]any `json:"data"`
} `json:"error"`
ID any `json:"id"`
}
if err := json.Unmarshal(wireBody, &decoded); err != nil {
t.Fatalf("unmarshaling response body: %v", err)
}

if decoded.JSONRPC != "2.0" {
t.Errorf("jsonrpc = %q, want \"2.0\"", decoded.JSONRPC)
}
if decoded.Error.Code != tt.wantCode {
t.Errorf("code = %d, want %d", decoded.Error.Code, tt.wantCode)
}
if decoded.Error.Message != tt.err.Error() {
t.Errorf("message = %q, want %q", decoded.Error.Message, tt.err.Error())
}
if tt.wantData && len(decoded.Error.Data) == 0 {
t.Errorf("expected non-empty data, got %v", decoded.Error.Data)
}
if !tt.wantData && len(decoded.Error.Data) != 0 {
t.Errorf("expected no data, got %v", decoded.Error.Data)
}

gotID, wantID := decoded.ID, tt.requestID
if wantID == nil {
if gotID != nil {
t.Errorf("id = %v, want nil", gotID)
}
} else if gotID != wantID {
t.Errorf("id = %v (%T), want %v (%T)", gotID, gotID, wantID, wantID)
}
})
}
}

// TestClassificationErrorBodyMarshalFallback verifies the defensive fallback:
// if the response fails to marshal (unreachable in production, where requestID
// is always a json.RawMessage), classificationErrorBody still returns a valid
// JSON-RPC error rather than an empty body. A channel is not JSON-marshalable,
// so it forces json.Marshal to fail.
func TestClassificationErrorBodyMarshalFallback(t *testing.T) {
t.Parallel()

const want = `{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":null}`
got := classificationErrorBody(make(chan int), errors.New("boom"))
if string(got) != want {
t.Fatalf("fallback body = %s, want %s", got, want)
}
}
45 changes: 1 addition & 44 deletions pkg/transport/proxy/streamable/streamable_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ func (p *HTTPProxy) resolveSessionForRequest(
protoHeader := r.Header.Get("MCP-Protocol-Version")
rev, err := mcp.ClassifyRevision(req.Method, meta, protoHeader)
if err != nil {
writeClassificationError(w, req.ID.Raw(), err)
mcp.WriteClassificationError(w, req.ID.Raw(), err)
return "", false, err
}

Expand Down Expand Up @@ -841,49 +841,6 @@ func (p *HTTPProxy) resolveSessionForRequest(
return sessID, false, nil
}

// writeClassificationError renders an mcp.ClassifyRevision error as an HTTP 400
// JSON-RPC error response, modeled on session.NotFoundBody/WriteNotFound: the
// body is marshaled first (with a hand-crafted fallback on marshal failure) so
// headers and status are only written once a valid body is ready.
// It uses the error's Code(), Error() message, and Data() (when non-empty) if
// the error implements mcp.CodedError, falling back to the standard JSON-RPC
// Invalid Params code otherwise -- a fallback that is currently unreachable,
// since every error ClassifyRevision returns implements mcp.CodedError.
func writeClassificationError(w http.ResponseWriter, requestID any, err error) {
code := mcp.CodeInvalidParams
var coded mcp.CodedError
var data map[string]any
if errors.As(err, &coded) {
code = coded.Code()
data = coded.Data()
}

errBody := map[string]any{
"code": code,
"message": err.Error(),
}
if len(data) > 0 {
errBody["data"] = data
}
resp := map[string]any{
"jsonrpc": "2.0",
"error": errBody,
"id": requestID,
}

body, marshalErr := json.Marshal(resp)
if marshalErr != nil {
// This should never happen with simple map types, but return a
// hand-crafted fallback to guarantee a valid JSON-RPC error.
body = []byte(`{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":null}`)
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
//nolint:gosec // G104: writing a JSON-RPC error response to an HTTP client
_, _ = w.Write(body)
}

func isBatch(body []byte) bool {
t := bytes.TrimSpace(body)
return len(t) > 0 && t[0] == '['
Expand Down
Loading
Loading