mcp connection
This commit is contained in:
595
go-api/internal/mcpserver/mcpserver_test.go
Normal file
595
go-api/internal/mcpserver/mcpserver_test.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// The tool registry these tests run against is the REAL one — the same
|
||||
// construction the service uses — with a nil database and a nil retriever.
|
||||
//
|
||||
// That is sound because nothing here dispatches: Phase 1's tools/call refuses
|
||||
// before reaching a handler, and every other assertion is about metadata, which
|
||||
// is fixed at registration. Testing against a hand-built fixture registry would
|
||||
// be testing a copy of the thing under test: the whole claim of this package is
|
||||
// "what MCP publishes is what the registry holds", and a fixture would let that
|
||||
// claim pass while being false of the real set.
|
||||
// Both dependencies are nil: the handlers capture them in closures and nothing
|
||||
// here reaches a handler, so nothing dereferences them. If a future test does
|
||||
// dispatch, this will nil-panic loudly rather than quietly reading a database
|
||||
// it should not have.
|
||||
func testRegistry(t *testing.T) *tools.Registry {
|
||||
t.Helper()
|
||||
return runtime.DefaultTools(nil, nil)
|
||||
}
|
||||
|
||||
// newServer wires the real registry to a test-only token authenticator.
|
||||
//
|
||||
// PHASE 2 NOTE: tools/list and tools/call now require a bearer token, so this
|
||||
// fixture authenticates. Not one assertion in this file changed — only the
|
||||
// fixture gained a credential. The behaviour change is the point of Phase 2 and
|
||||
// is asserted directly in auth_test.go (TestBearerRejection), rather than being
|
||||
// papered over here.
|
||||
func newServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
return New(testRegistry(t), &fakeTokens{byToken: map[string]authctx.Identity{
|
||||
validToken: identityFor("org-test", "admin"),
|
||||
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
// post sends one raw body to the MCP endpoint and returns the recorder.
|
||||
func post(t *testing.T, s *Server, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+validToken)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// decode reads a JSON-RPC response, failing the test if the envelope is wrong.
|
||||
func decode(t *testing.T, rec *httptest.ResponseRecorder) response {
|
||||
t.Helper()
|
||||
var resp response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response was not JSON: %v\nbody: %s", err, rec.Body.String())
|
||||
}
|
||||
if resp.JSONRPC != jsonRPCVersion {
|
||||
t.Fatalf("jsonrpc = %q, want %q", resp.JSONRPC, jsonRPCVersion)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// resultInto re-decodes a successful result into dst.
|
||||
func resultInto(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response was not JSON: %v", err)
|
||||
}
|
||||
if envelope.Error != nil {
|
||||
t.Fatalf("expected a result, got error %d: %s", envelope.Error.Code, envelope.Error.Message)
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, dst); err != nil {
|
||||
t.Fatalf("result did not decode: %v\nresult: %s", err, envelope.Result)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 1. initialize ──────────────────────────────────────────────────────── */
|
||||
|
||||
func TestInitializeSucceeds(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18",
|
||||
"capabilities":{},
|
||||
"clientInfo":{"name":"test-client","version":"1.0"}}}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
var out initializeResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
if out.ProtocolVersion != ProtocolVersion {
|
||||
t.Errorf("protocolVersion = %q, want %q", out.ProtocolVersion, ProtocolVersion)
|
||||
}
|
||||
if out.ServerInfo["name"] != ServerName {
|
||||
t.Errorf("serverInfo.name = %v, want %q", out.ServerInfo["name"], ServerName)
|
||||
}
|
||||
// Exactly one capability, because exactly one is implemented. Advertising
|
||||
// resources or prompts would promise methods that answer method-not-found.
|
||||
if _, ok := out.Capabilities["tools"]; !ok {
|
||||
t.Error("capabilities.tools is missing")
|
||||
}
|
||||
for _, unimplemented := range []string{"resources", "prompts", "sampling", "logging"} {
|
||||
if _, ok := out.Capabilities[unimplemented]; ok {
|
||||
t.Errorf("capabilities advertises %q, which is not implemented", unimplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeEchoesTheRequestID(t *testing.T) {
|
||||
s := newServer(t)
|
||||
// A string id, to prove ids are echoed verbatim rather than coerced.
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":"abc-123","method":"initialize","params":{}}`)
|
||||
resp := decode(t, rec)
|
||||
if string(resp.ID) != `"abc-123"` {
|
||||
t.Errorf("id = %s, want \"abc-123\"", resp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 2 & 3. tools/list and exact exposure ───────────────────────────────── */
|
||||
|
||||
// wantExposed is the Phase 0 read-only MVP set, written out in full.
|
||||
//
|
||||
// Deliberately a literal rather than a filter over the registry: a test that
|
||||
// derived the expectation the same way the code does would pass no matter what
|
||||
// the rule became. This is the list a human agreed to, and it is the thing that
|
||||
// should fail when the rule changes.
|
||||
var wantExposed = []string{
|
||||
"activity_breakdown",
|
||||
"activity_signals",
|
||||
"available_workers",
|
||||
"candidates_awaiting",
|
||||
"candidates_quality",
|
||||
"hires_performance",
|
||||
"hires_recent",
|
||||
"open_positions",
|
||||
"operations_risk",
|
||||
"positions_risk",
|
||||
"talent_pool",
|
||||
"workforce_attendance",
|
||||
"workforce_coverage",
|
||||
"workforce_overtime",
|
||||
"workforce_training",
|
||||
"workspace_summary",
|
||||
}
|
||||
|
||||
func TestToolsListExposesExactlyTheReadOnlyMVPSet(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
|
||||
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
got := make([]string, 0, len(out.Tools))
|
||||
for _, tool := range out.Tools {
|
||||
got = append(got, tool.Name)
|
||||
}
|
||||
|
||||
if len(got) != len(wantExposed) {
|
||||
t.Fatalf("exposed %d tools, want %d\n got: %v\nwant: %v",
|
||||
len(got), len(wantExposed), got, wantExposed)
|
||||
}
|
||||
for i := range wantExposed {
|
||||
if got[i] != wantExposed[i] {
|
||||
t.Errorf("tool[%d] = %q, want %q", i, got[i], wantExposed[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsListWorksWithoutParams(t *testing.T) {
|
||||
s := newServer(t)
|
||||
// No params key at all — every exposed tool's arguments are optional, so a
|
||||
// bare list call must work.
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
if len(out.Tools) == 0 {
|
||||
t.Fatal("tools/list returned nothing")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 4 & 5. write tools and knowledge_search are not exposed ────────────── */
|
||||
|
||||
func TestNoWriteToolIsExposed(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":4,"method":"tools/list"}`)
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
reg := testRegistry(t)
|
||||
for _, published := range out.Tools {
|
||||
tool, ok := reg.Get(published.Name)
|
||||
if !ok {
|
||||
t.Fatalf("tools/list published %q, which is not in the registry", published.Name)
|
||||
}
|
||||
if tool.Effect != tools.EffectRead {
|
||||
t.Errorf("%q is exposed with effect %q; only read tools may be exposed",
|
||||
tool.Name, tool.Effect)
|
||||
}
|
||||
if tool.RequiresConfirmation {
|
||||
t.Errorf("%q is exposed and requires confirmation, which this surface cannot obtain",
|
||||
tool.Name)
|
||||
}
|
||||
// The annotation must agree with the registry, or a client shows a
|
||||
// person the wrong thing before approving.
|
||||
if published.Annotations == nil || !published.Annotations.ReadOnlyHint {
|
||||
t.Errorf("%q is not annotated readOnlyHint", tool.Name)
|
||||
}
|
||||
if published.Annotations != nil && published.Annotations.DestructiveHint {
|
||||
t.Errorf("%q is annotated destructiveHint", tool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedExclusionsAreNotExposed(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`)
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
published := map[string]bool{}
|
||||
for _, tool := range out.Tools {
|
||||
published[tool.Name] = true
|
||||
}
|
||||
// The two writes and the one deferred read. Named explicitly, because these
|
||||
// three are the ones a future change is most likely to let through.
|
||||
for _, forbidden := range []string{"assign_worker", "move_application", "knowledge_search"} {
|
||||
if published[forbidden] {
|
||||
t.Errorf("%q must not be exposed", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The registry must still hold the excluded tools — they are withheld from this
|
||||
// surface, not removed from KROW. A test that only checked absence would pass
|
||||
// if somebody deleted them.
|
||||
func TestExcludedToolsStillExistInTheRegistry(t *testing.T) {
|
||||
reg := testRegistry(t)
|
||||
for _, name := range []string{"assign_worker", "move_application", "knowledge_search"} {
|
||||
if _, ok := reg.Get(name); !ok {
|
||||
t.Errorf("%q is missing from the registry entirely", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 6. schemas come from the real registry ─────────────────────────────── */
|
||||
|
||||
func TestToolSchemaIsSourcedFromTheRegistry(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":6,"method":"tools/list"}`)
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
reg := testRegistry(t)
|
||||
for _, published := range out.Tools {
|
||||
tool, _ := reg.Get(published.Name)
|
||||
|
||||
if published.Description != tool.Description {
|
||||
t.Errorf("%q description differs from the registry's", tool.Name)
|
||||
}
|
||||
if published.InputSchema == nil {
|
||||
t.Fatalf("%q published a nil inputSchema", tool.Name)
|
||||
}
|
||||
// Every exposed tool declares a schema, so the published one must be
|
||||
// the registry's own map and not the empty-schema fallback.
|
||||
if tool.InputSchema == nil {
|
||||
t.Errorf("%q has no InputSchema in the registry; expected every exposed tool to declare one", tool.Name)
|
||||
continue
|
||||
}
|
||||
wantJSON, _ := json.Marshal(tool.InputSchema)
|
||||
gotJSON, _ := json.Marshal(published.InputSchema)
|
||||
if string(wantJSON) != string(gotJSON) {
|
||||
t.Errorf("%q schema differs from the registry's\n got: %s\nwant: %s",
|
||||
tool.Name, gotJSON, wantJSON)
|
||||
}
|
||||
// A published schema must be a JSON Schema object, or a client may
|
||||
// refuse the whole list.
|
||||
if published.InputSchema["type"] != "object" {
|
||||
t.Errorf("%q schema type = %v, want \"object\"", tool.Name, published.InputSchema["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No exposed tool may take a tenant or principal identifier as an argument.
|
||||
// An argument is something a client chooses; identity is not the client's to
|
||||
// choose. Enforced here rather than by review, because the cost of missing it
|
||||
// once is cross-tenant access.
|
||||
func TestNoToolAcceptsATenantOrPrincipalArgument(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":7,"method":"tools/list"}`)
|
||||
var out toolsListResult
|
||||
resultInto(t, rec, &out)
|
||||
|
||||
forbidden := []string{
|
||||
"org_id", "orgid", "organization_id", "organisation_id",
|
||||
"tenant_id", "tenantid", "tenant",
|
||||
"user_id", "userid", "principal", "principal_id",
|
||||
"account_id", "caller", "caller_id", "on_behalf_of", "impersonate",
|
||||
}
|
||||
|
||||
for _, tool := range out.Tools {
|
||||
props, ok := tool.InputSchema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for field := range props {
|
||||
lower := strings.ToLower(field)
|
||||
for _, bad := range forbidden {
|
||||
if lower == bad {
|
||||
t.Errorf("%q accepts %q as an argument; identity must come from the "+
|
||||
"authenticated principal, never from the request", tool.Name, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 7 & 8. unknown method, unknown tool ────────────────────────────────── */
|
||||
|
||||
func TestUnknownMethodReturnsMethodNotFound(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":8,"method":"resources/list"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 — a JSON-RPC error is still a successful HTTP exchange", rec.Code)
|
||||
}
|
||||
resp := decode(t, rec)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if resp.Error.Code != codeMethodNotFound {
|
||||
t.Errorf("code = %d, want %d", resp.Error.Code, codeMethodNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownToolIsReportedAsAToolError(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":9,"method":"tools/call",
|
||||
"params":{"name":"no_such_tool","arguments":{}}}`)
|
||||
|
||||
var out toolsCallResult
|
||||
resultInto(t, rec, &out)
|
||||
if !out.IsError {
|
||||
t.Error("expected isError on an unknown tool")
|
||||
}
|
||||
if len(out.Content) == 0 || !strings.Contains(out.Content[0].Text, "mcp.unknown_tool") {
|
||||
t.Errorf("expected mcp.unknown_tool, got %+v", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// An unexposed tool must be indistinguishable from a non-existent one, or the
|
||||
// error messages become an inventory of what this surface is withholding.
|
||||
func TestUnexposedToolIsIndistinguishableFromUnknown(t *testing.T) {
|
||||
s := newServer(t)
|
||||
|
||||
unknown := post(t, s, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"definitely_not_a_tool"}}`)
|
||||
withheld := post(t, s, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"assign_worker"}}`)
|
||||
|
||||
var a, b toolsCallResult
|
||||
resultInto(t, unknown, &a)
|
||||
resultInto(t, withheld, &b)
|
||||
|
||||
codeOf := func(r toolsCallResult) string {
|
||||
var payload struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(r.Content[0].Text), &payload)
|
||||
return payload.Error.Code
|
||||
}
|
||||
if codeOf(a) != codeOf(b) {
|
||||
t.Errorf("an unexposed tool is distinguishable from an unknown one: %q vs %q",
|
||||
codeOf(a), codeOf(b))
|
||||
}
|
||||
if a.IsError != b.IsError {
|
||||
t.Error("isError differs between unknown and unexposed tools")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 9 & 10. malformed JSON and params ──────────────────────────────────── */
|
||||
|
||||
func TestMalformedJSONReturnsParseError(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":1,"method":`)
|
||||
|
||||
resp := decode(t, rec)
|
||||
if resp.Error == nil || resp.Error.Code != codeParseError {
|
||||
t.Fatalf("want parse error %d, got %+v", codeParseError, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeValidation(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
"missing jsonrpc": {`{"id":1,"method":"tools/list"}`, codeInvalidRequest},
|
||||
"wrong jsonrpc": {`{"jsonrpc":"1.0","id":1,"method":"tools/list"}`, codeInvalidRequest},
|
||||
"missing method": {`{"jsonrpc":"2.0","id":1}`, codeInvalidRequest},
|
||||
"empty body": {``, codeInvalidRequest},
|
||||
"object id": {`{"jsonrpc":"2.0","id":{"a":1},"method":"tools/list"}`, codeInvalidRequest},
|
||||
"not an object": {`"a string"`, codeParseError},
|
||||
"params as array": {`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":[1,2]}`, codeInvalidParams},
|
||||
"params wrong type": {`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":42}}`, codeInvalidParams},
|
||||
"missing tool name": {`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}`, codeInvalidParams},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := post(t, newServer(t), tc.body)
|
||||
resp := decode(t, rec)
|
||||
if resp.Error == nil {
|
||||
t.Fatalf("expected an error, got %s", rec.Body.String())
|
||||
}
|
||||
if resp.Error.Code != tc.want {
|
||||
t.Errorf("code = %d, want %d (%s)", resp.Error.Code, tc.want, resp.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 11. batching is explicitly rejected ────────────────────────────────── */
|
||||
|
||||
func TestBatchRequestsAreExplicitlyRejected(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `[{"jsonrpc":"2.0","id":1,"method":"tools/list"},
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list"}]`)
|
||||
|
||||
resp := decode(t, rec)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("a batch must be refused")
|
||||
}
|
||||
if resp.Error.Code != codeInvalidRequest {
|
||||
t.Errorf("code = %d, want %d", resp.Error.Code, codeInvalidRequest)
|
||||
}
|
||||
// The refusal must say WHY, so a client can fall back to sending singly
|
||||
// rather than retrying the same batch forever.
|
||||
detail, _ := resp.Error.Data.(string)
|
||||
if !strings.Contains(detail, "batch") {
|
||||
t.Errorf("the refusal does not mention batching: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 12. no panic on malformed input ────────────────────────────────────── */
|
||||
|
||||
func TestNoPanicOnHostileInput(t *testing.T) {
|
||||
// Each of these has crashed a hand-written JSON-RPC server somewhere.
|
||||
bodies := []string{
|
||||
``,
|
||||
` `,
|
||||
`null`,
|
||||
`[]`,
|
||||
`[[[[[]]]]]`,
|
||||
`{`,
|
||||
`}`,
|
||||
`{"jsonrpc":"2.0"}`,
|
||||
`{"jsonrpc":null,"id":null,"method":null}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":null}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":""}}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"activity_breakdown","arguments":"not-an-object"}}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"activity_breakdown","arguments":[]}}`,
|
||||
`{"jsonrpc":"2.0","id":[1,2,3],"method":"tools/list"}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"` + strings.Repeat("A", 10_000) + `"}`,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":` + strings.Repeat("[", 500) + `}}`,
|
||||
"\x00\x01\x02",
|
||||
}
|
||||
|
||||
s := newServer(t)
|
||||
for i, body := range bodies {
|
||||
// A panic escaping here fails the test by crashing it, which is the
|
||||
// assertion: the handler must answer every one of these.
|
||||
rec := post(t, s, body)
|
||||
if rec.Code < 200 || rec.Code >= 600 {
|
||||
t.Errorf("body %d produced status %d", i, rec.Code)
|
||||
}
|
||||
if rec.Body.Len() > 0 && rec.Code != http.StatusAccepted {
|
||||
var resp response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Errorf("body %d produced a non-JSON response: %s", i, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transport ──────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestOnlyPOSTIsAccepted(t *testing.T) {
|
||||
s := newServer(t)
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
req := httptest.NewRequest(method, "/mcp", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s: status = %d, want 405", method, rec.Code)
|
||||
}
|
||||
if allow := rec.Header().Get("Allow"); allow != http.MethodPost {
|
||||
t.Errorf("%s: Allow = %q, want POST", method, allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonJSONContentTypeIsRefused(t *testing.T) {
|
||||
s := newServer(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("status = %d, want 415", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedBodyIsRefused(t *testing.T) {
|
||||
s := newServer(t)
|
||||
huge := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":"` +
|
||||
strings.Repeat("x", MaxRequestBytes+1) + `"}}`
|
||||
rec := post(t, s, huge)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want 413", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationGetsNoBody(t *testing.T) {
|
||||
s := newServer(t)
|
||||
// No id — a notification. The spec forbids a response.
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Errorf("status = %d, want 202", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("a notification was answered with a body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Phase 1 boundary ───────────────────────────────────────────────────── */
|
||||
|
||||
// Handle must refuse a nil identity even when called directly.
|
||||
//
|
||||
// The transport answers 401 before this point in the ordinary case, so this is
|
||||
// the SECOND gate: a future caller of Handle that forgets to authenticate must
|
||||
// fail closed rather than dispatch as nobody. It exists to fail loudly if
|
||||
// somebody later supplies a default identity to "make it work".
|
||||
func TestHandleRefusesANilIdentity(t *testing.T) {
|
||||
s := newServer(t)
|
||||
req := request{
|
||||
JSONRPC: jsonRPCVersion,
|
||||
ID: json.RawMessage(`11`),
|
||||
Method: "tools/call",
|
||||
Params: json.RawMessage(`{"name":"activity_breakdown","arguments":{}}`),
|
||||
}
|
||||
result, rpcErr := s.Handle(context.Background(), nil, req)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("unexpected rpc error: %v", rpcErr)
|
||||
}
|
||||
out, ok := result.(toolsCallResult)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected result type %T", result)
|
||||
}
|
||||
if !out.IsError || !strings.Contains(out.Content[0].Text, "mcp.unauthenticated") {
|
||||
t.Errorf("expected mcp.unauthenticated, got: %+v", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingIsCheap(t *testing.T) {
|
||||
s := newServer(t)
|
||||
rec := post(t, s, `{"jsonrpc":"2.0","id":12,"method":"ping"}`)
|
||||
var out map[string]any
|
||||
resultInto(t, rec, &out)
|
||||
if len(out) != 0 {
|
||||
t.Errorf("ping returned %v, want an empty result", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user