// Contract tests: these pin the MCP wire contract of this server — the parts // of a tools/list and tools/call response that strict clients (e.g. // pydantic-based MCP SDKs) validate on every connection. // // Background: get_all_pages once returned a bare []PageListItem. The SDK then // inferred outputSchema.type ["null","array"], which violates the MCP spec // (type must be the string "object") and made strict clients reject the whole // tools/list response with a ValidationError — the server connected but // silently offered no tools. These tests keep that class of bug out. // // All schema assertions run against the wire format (re-marshaled JSON), not // against Go structs, because the wire format is what strict clients see. package server import ( "context" "encoding/json" "slices" "strings" "testing" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) // wireType returns the wire `type` value of a JSON schema as raw JSON, so the // string "object" and the invalid-ish array ["null","array"] are // distinguishable. Returns nil if the schema has no type. func wireType(t *testing.T, schema any) json.RawMessage { t.Helper() if schema == nil { return nil } raw, err := json.Marshal(schema) if err != nil { t.Fatalf("marshal schema: %v", err) } var out struct { Type json.RawMessage `json:"type"` } if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("unmarshal schema: %v", err) } return out.Type } func listedTools(t *testing.T, cs *mcp.ClientSession) []*mcp.Tool { t.Helper() res, err := cs.ListTools(context.Background(), nil) if err != nil { t.Fatalf("ListTools() error = %v", err) } if len(res.Tools) == 0 { t.Fatal("ListTools() returned no tools") } return res.Tools } func requireObjectSchemas(t *testing.T, tool *mcp.Tool) { t.Helper() if tool.InputSchema == nil { t.Errorf("tool %q has no inputSchema", tool.Name) } else if got := wireType(t, tool.InputSchema); string(got) != `"object"` { t.Errorf("tool %q inputSchema.type = %s, want \"object\"", tool.Name, got) } if tool.OutputSchema != nil { if got := wireType(t, tool.OutputSchema); string(got) != `"object"` { t.Errorf("tool %q outputSchema.type = %s, want \"object\"", tool.Name, got) } } } // TestWireSchemasAreObjects pins the rule that broke Hermes: every declared // schema must have type "object" on the wire. inputSchema must always be an // object; outputSchema must be an object whenever it is declared at all // (absent outputSchema is optional per the MCP spec). Uses an edit-enabled // session so all six tools are covered. func TestWireSchemasAreObjects(t *testing.T) { cs, _ := newTestSession(t, true) for _, tool := range listedTools(t, cs) { t.Run(tool.Name, func(t *testing.T) { requireObjectSchemas(t, tool) }) } } // toolArgContract pins the public tool surface: names and argument names are a // stable API (camelCase included), and required/optional must not drift. var toolArgContract = map[string]struct { required []string optional []string }{ "search_pages": {required: []string{"query"}, optional: []string{"locale", "path"}}, "get_page_by_id": {required: []string{"id"}}, "get_page_by_path": {required: []string{"locale", "path"}}, "get_all_pages": {optional: []string{"limit", "locale", "tags"}}, "create_page": { required: []string{"content", "description", "path", "title"}, optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"}, }, "edit_page": { required: []string{"content", "description", "id", "path", "title"}, optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"}, }, } func sortedClone(values []string) []string { out := slices.Clone(values) slices.Sort(out) return out } // TestToolArgumentContract pins tool names and argument names (including // camelCase such as isPublished) plus the required/optional split of every // tool. A mismatch means the public API changed and README.md, // AGENTS.md, and every client integration need a conscious update. func TestToolArgumentContract(t *testing.T) { cs, _ := newTestSession(t, true) tools := listedTools(t, cs) seen := map[string]bool{} for _, tool := range tools { seen[tool.Name] = true want, ok := toolArgContract[tool.Name] if !ok { t.Errorf("unexpected tool %q: not in the argument contract; update toolArgContract", tool.Name) continue } raw, err := json.Marshal(tool.InputSchema) if err != nil { t.Fatalf("marshal inputSchema of %q: %v", tool.Name, err) } var schema struct { Properties map[string]json.RawMessage `json:"properties"` Required []string `json:"required"` } if err := json.Unmarshal(raw, &schema); err != nil { t.Fatalf("unmarshal inputSchema of %q: %v", tool.Name, err) } var optional []string for name := range schema.Properties { if !slices.Contains(schema.Required, name) { optional = append(optional, name) } } if got, want := sortedClone(schema.Required), sortedClone(want.required); !slices.Equal(got, want) { t.Errorf("tool %q required args = %v, want %v", tool.Name, got, want) } if got, want := sortedClone(optional), sortedClone(want.optional); !slices.Equal(got, want) { t.Errorf("tool %q optional args = %v, want %v", tool.Name, got, want) } } for name := range toolArgContract { if !seen[name] { t.Errorf("tool %q is in the contract but not listed by the server", name) } } } // validateStructuredContent re-marshals the declared output schema, resolves // it, and validates a real structuredContent against it — the same // self-consistency check a strict client performs. func validateStructuredContent(t *testing.T, tool *mcp.Tool, structured any) { t.Helper() raw, err := json.Marshal(tool.OutputSchema) if err != nil { t.Fatalf("marshal outputSchema of %q: %v", tool.Name, err) } var schema jsonschema.Schema if err := json.Unmarshal(raw, &schema); err != nil { t.Fatalf("unmarshal outputSchema of %q: %v", tool.Name, err) } resolved, err := schema.Resolve(nil) if err != nil { t.Fatalf("resolve outputSchema of %q: %v", tool.Name, err) } if err := resolved.Validate(structured); err != nil { t.Errorf("structuredContent of %q does not match its declared outputSchema: %v", tool.Name, err) } } func findTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool { t.Helper() for _, tool := range tools { if tool.Name == name { return tool } } t.Fatalf("tool %q not listed", name) return nil } // TestStructuredContentMatchesDeclaredSchema calls every tool that declares an // outputSchema and validates the returned structuredContent against that // schema. What we announce in tools/list must be what we deliver in // tools/call. func TestStructuredContentMatchesDeclaredSchema(t *testing.T) { cs, fake := newTestSession(t, true) tools := listedTools(t, cs) cases := []struct { name string args map[string]any }{ {name: "search_pages", args: map[string]any{"query": "mcp"}}, {name: "get_all_pages"}, {name: "create_page", args: map[string]any{ "title": "New", "content": "body", "description": "desc", "path": "/new", }}, {name: "edit_page", args: map[string]any{ "id": 1, "title": "Edit", "content": "body", "description": "desc", "path": "/edit", }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { tool := findTool(t, tools, tc.name) if tool.OutputSchema == nil { t.Fatalf("tool %q declares no outputSchema", tc.name) } res := callTool(t, cs, tc.name, tc.args) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } if res.StructuredContent == nil { t.Fatalf("structuredContent = nil, want a value matching the declared outputSchema") } validateStructuredContent(t, tool, res.StructuredContent) }) } // get_all_pages with an empty result must deliver {"pages": []}, never a // bare null: a nil Go slice would serialize as JSON null and violate the // required "pages" property of the declared schema. fake.emptyList = true t.Run("get_all_pages empty", func(t *testing.T) { tool := findTool(t, tools, "get_all_pages") res := callTool(t, cs, "get_all_pages", nil) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } raw, err := json.Marshal(res.StructuredContent) if err != nil { t.Fatalf("marshal structuredContent: %v", err) } if got, want := string(raw), `{"pages":[]}`; got != want { t.Errorf("structuredContent = %s, want %s", got, want) } validateStructuredContent(t, tool, res.StructuredContent) }) } // TestNullPageHasNoStructuredContent pins the semantic contract for a missing // page: the text content is exactly "null" and no structuredContent is sent. // A typed nil page coerced into structured content would surface as {} and // read like an existing empty page. func TestNullPageHasNoStructuredContent(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404}) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } if res.StructuredContent != nil { t.Errorf("structuredContent = %v, want none for a missing page", res.StructuredContent) } if got := strings.TrimSpace(toolText(t, res)); got != "null" { t.Errorf("text = %q, want \"null\"", got) } }