| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- // 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)
- }
- }
|