package pier_test import ( "context" "encoding/json" "errors" "io" "net/http" "strings" "sync/atomic" "testing" "time" "git.golem15.com/golem15/summercms/modules/pact" "github.com/modelcontextprotocol/go-sdk/mcp" ) type thingInput struct { ID string `json:"id" path:"id"` Tags []string `json:"tags,omitempty" query:"tag"` Title string `json:"title"` Note string `json:"note,omitempty"` } const invalidBody = `{"message":"The given data was invalid.","errors":{"title":["The title field is required."]}}` // TestDispatch (D-11, D-13, D-14): a call's path and query fields fill the // URL and the body is the agent's JSON minus them, without the fields the // agent omitted (Pitfall 6); a multi-segment value for a single segment is // refused before dispatch; the outer Authorization and Cookie never reach // the route while the client address, X-Forwarded-For and Accept-Language // do; a non-2xx answer is a tool error carrying the route's exact body as // text and structured content; a 2xx array is wrapped as {"data": [...]}; // an answer above mcp.max_result_bytes is a fixed tool error; the per-call // timeout cancels a hanging handler. func TestDispatch(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var thingHits atomic.Int64 hangErr := make(chan error, 1) echo := func(w http.ResponseWriter, r *http.Request) { thingHits.Add(1) raw, _ := io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": r.PathValue("id"), "tags": r.URL.Query()["tag"], "body": string(raw), "auth": r.Header.Get("Authorization"), "cookie": r.Header.Get("Cookie"), "xff": r.Header.Get("X-Forwarded-For"), "lang": r.Header.Get("Accept-Language"), "remote": r.RemoteAddr, }) } plugin := routePlugin{id: "test.disp", routes: []testRoute{ {method: http.MethodPost, path: "/disp/things/{id}", mw: []string{"acme.auth"}, h: echo}, {method: http.MethodPost, path: "/disp/invalid", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusUnprocessableEntity, invalidBody)}, {method: http.MethodGet, path: "/disp/list", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `[1,2,3]`)}, {method: http.MethodGet, path: "/disp/big", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `"`+strings.Repeat("x", 2048)+`"`)}, {method: http.MethodGet, path: "/disp/hang", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() hangErr <- r.Context().Err() w.WriteHeader(http.StatusServiceUnavailable) }}, }, tools: []pact.MCPTool{ {Name: "thing", Method: "POST", Path: "/disp/things/{id}", Input: thingInput{}}, {Name: "invalid", Method: "POST", Path: "/disp/invalid", Input: struct{}{}}, {Name: "list", Method: "GET", Path: "/disp/list", Input: struct{}{}}, {Name: "big", Method: "GET", Path: "/disp/big", Input: struct{}{}}, {Name: "hang", Method: "GET", Path: "/disp/hang", Input: struct{}{}}, }} app := newApp(t, appSpec{mcp: "enabled: false\nmax_result_bytes: 1024\ncall_timeout: 1s\n", guards: []pact.MCPGuard{&fakeGuard{}}}) in := startInstance(t, app, plugin) cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{ "Cookie": "summer_session=outer; summer_admin=outer", "X-Forwarded-For": "203.0.113.9", "Accept-Language": "pl", }}) call := func(name string, args map[string]any) *mcp.CallToolResult { t.Helper() res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) if err != nil { t.Fatalf("%s: %v", name, err) } return res } t.Run("argument split and request shape", func(t *testing.T) { res := call("disp_thing", map[string]any{"id": "abc", "tags": []string{"a", "b"}, "title": "T"}) if res.IsError { t.Fatalf("isError: %s", resultText(t, res)) } got := structured(t, res) if got["id"] != "abc" || got["body"] != `{"title":"T"}` { t.Fatalf("path and body = %v, %v", got["id"], got["body"]) } if tags, _ := got["tags"].([]any); len(tags) != 2 || tags[0] != "a" || tags[1] != "b" { t.Fatalf("query tags = %v", got["tags"]) } if got["auth"] != "" || got["cookie"] != "" { t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"]) } if got["xff"] != "203.0.113.9" || got["lang"] != "pl" || !strings.HasPrefix(got["remote"].(string), "127.0.0.1:") { t.Fatalf("forwarded request facts = xff %v lang %v remote %v", got["xff"], got["lang"], got["remote"]) } if text := resultText(t, res); !strings.Contains(text, `"id":"abc"`) { t.Fatalf("text content = %s", text) } }) t.Run("multi-segment value refused before dispatch", func(t *testing.T) { before := thingHits.Load() res := call("disp_thing", map[string]any{"id": "a/b", "title": "T"}) if !res.IsError || !strings.Contains(resultText(t, res), "one non-empty path segment") { t.Fatalf("a/b = %+v", res) } res = call("disp_thing", map[string]any{"id": "abc"}) if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") { t.Fatalf("missing required title = %+v", res) } // The reflected schema refuses keys the input struct does not // declare (additionalProperties false), so nothing undeclared // reaches the route's body. res = call("disp_thing", map[string]any{"id": "abc", "title": "T", "user_id": 1}) if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") { t.Fatalf("undeclared key = %+v", res) } if thingHits.Load() != before { t.Fatal("a refused call reached the route") } }) t.Run("route error is a tool error with its body", func(t *testing.T) { res := call("disp_invalid", nil) if !res.IsError || resultText(t, res) != invalidBody { t.Fatalf("422 = %+v %q", res, resultText(t, res)) } got := structured(t, res) if got["message"] != "The given data was invalid." || got["errors"] == nil { t.Fatalf("422 structuredContent = %+v", got) } }) t.Run("2xx array wrapped as data", func(t *testing.T) { res := call("disp_list", nil) data, _ := structured(t, res)["data"].([]any) if res.IsError || len(data) != 3 || data[0] != 1.0 || resultText(t, res) != "[1,2,3]" { t.Fatalf("array = %+v", res) } }) t.Run("result above max_result_bytes", func(t *testing.T) { res := call("disp_big", nil) if !res.IsError || resultText(t, res) != "pier: the route's answer exceeds mcp.max_result_bytes" || strings.Contains(resultText(t, res), "xxxx") { t.Fatalf("big = %+v", res) } }) t.Run("call timeout cancels the handler", func(t *testing.T) { start := time.Now() res := call("disp_hang", nil) if took := time.Since(start); took > 10*time.Second || took < 900*time.Millisecond { t.Fatalf("hang returned after %v, want about the 1s call_timeout", took) } if !res.IsError { t.Fatalf("hang = %+v, want a tool error", res) } select { case err := <-hangErr: if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("handler context ended with %v, want the deadline", err) } case <-time.After(5 * time.Second): t.Fatal("the hanging handler never saw its context end") } }) }