596 lines
21 KiB
Go
596 lines
21 KiB
Go
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)
|
|
}
|
|
}
|