contract_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. // Contract tests: these pin the MCP wire contract of this server — the parts
  2. // of a tools/list and tools/call response that strict clients (e.g.
  3. // pydantic-based MCP SDKs) validate on every connection.
  4. //
  5. // Background: get_all_pages once returned a bare []PageListItem. The SDK then
  6. // inferred outputSchema.type ["null","array"], which violates the MCP spec
  7. // (type must be the string "object") and made strict clients reject the whole
  8. // tools/list response with a ValidationError — the server connected but
  9. // silently offered no tools. These tests keep that class of bug out.
  10. //
  11. // All schema assertions run against the wire format (re-marshaled JSON), not
  12. // against Go structs, because the wire format is what strict clients see.
  13. package server
  14. import (
  15. "context"
  16. "encoding/json"
  17. "slices"
  18. "strings"
  19. "testing"
  20. "github.com/google/jsonschema-go/jsonschema"
  21. "github.com/modelcontextprotocol/go-sdk/mcp"
  22. )
  23. // wireType returns the wire `type` value of a JSON schema as raw JSON, so the
  24. // string "object" and the invalid-ish array ["null","array"] are
  25. // distinguishable. Returns nil if the schema has no type.
  26. func wireType(t *testing.T, schema any) json.RawMessage {
  27. t.Helper()
  28. if schema == nil {
  29. return nil
  30. }
  31. raw, err := json.Marshal(schema)
  32. if err != nil {
  33. t.Fatalf("marshal schema: %v", err)
  34. }
  35. var out struct {
  36. Type json.RawMessage `json:"type"`
  37. }
  38. if err := json.Unmarshal(raw, &out); err != nil {
  39. t.Fatalf("unmarshal schema: %v", err)
  40. }
  41. return out.Type
  42. }
  43. func listedTools(t *testing.T, cs *mcp.ClientSession) []*mcp.Tool {
  44. t.Helper()
  45. res, err := cs.ListTools(context.Background(), nil)
  46. if err != nil {
  47. t.Fatalf("ListTools() error = %v", err)
  48. }
  49. if len(res.Tools) == 0 {
  50. t.Fatal("ListTools() returned no tools")
  51. }
  52. return res.Tools
  53. }
  54. func requireObjectSchemas(t *testing.T, tool *mcp.Tool) {
  55. t.Helper()
  56. if tool.InputSchema == nil {
  57. t.Errorf("tool %q has no inputSchema", tool.Name)
  58. } else if got := wireType(t, tool.InputSchema); string(got) != `"object"` {
  59. t.Errorf("tool %q inputSchema.type = %s, want \"object\"", tool.Name, got)
  60. }
  61. if tool.OutputSchema != nil {
  62. if got := wireType(t, tool.OutputSchema); string(got) != `"object"` {
  63. t.Errorf("tool %q outputSchema.type = %s, want \"object\"", tool.Name, got)
  64. }
  65. }
  66. }
  67. // TestWireSchemasAreObjects pins the rule that broke Hermes: every declared
  68. // schema must have type "object" on the wire. inputSchema must always be an
  69. // object; outputSchema must be an object whenever it is declared at all
  70. // (absent outputSchema is optional per the MCP spec). Uses an edit-enabled
  71. // session so all six tools are covered.
  72. func TestWireSchemasAreObjects(t *testing.T) {
  73. cs, _ := newTestSession(t, true)
  74. for _, tool := range listedTools(t, cs) {
  75. t.Run(tool.Name, func(t *testing.T) {
  76. requireObjectSchemas(t, tool)
  77. })
  78. }
  79. }
  80. // toolArgContract pins the public tool surface: names and argument names are a
  81. // stable API (camelCase included), and required/optional must not drift.
  82. var toolArgContract = map[string]struct {
  83. required []string
  84. optional []string
  85. }{
  86. "search_pages": {required: []string{"query"}, optional: []string{"locale", "path"}},
  87. "get_page_by_id": {required: []string{"id"}},
  88. "get_page_by_path": {required: []string{"locale", "path"}},
  89. "get_all_pages": {optional: []string{"limit", "locale", "tags"}},
  90. "create_page": {
  91. required: []string{"content", "description", "path", "title"},
  92. optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"},
  93. },
  94. "edit_page": {
  95. required: []string{"content", "description", "id", "path", "title"},
  96. optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"},
  97. },
  98. }
  99. func sortedClone(values []string) []string {
  100. out := slices.Clone(values)
  101. slices.Sort(out)
  102. return out
  103. }
  104. // TestToolArgumentContract pins tool names and argument names (including
  105. // camelCase such as isPublished) plus the required/optional split of every
  106. // tool. A mismatch means the public API changed and README.md,
  107. // AGENTS.md, and every client integration need a conscious update.
  108. func TestToolArgumentContract(t *testing.T) {
  109. cs, _ := newTestSession(t, true)
  110. tools := listedTools(t, cs)
  111. seen := map[string]bool{}
  112. for _, tool := range tools {
  113. seen[tool.Name] = true
  114. want, ok := toolArgContract[tool.Name]
  115. if !ok {
  116. t.Errorf("unexpected tool %q: not in the argument contract; update toolArgContract", tool.Name)
  117. continue
  118. }
  119. raw, err := json.Marshal(tool.InputSchema)
  120. if err != nil {
  121. t.Fatalf("marshal inputSchema of %q: %v", tool.Name, err)
  122. }
  123. var schema struct {
  124. Properties map[string]json.RawMessage `json:"properties"`
  125. Required []string `json:"required"`
  126. }
  127. if err := json.Unmarshal(raw, &schema); err != nil {
  128. t.Fatalf("unmarshal inputSchema of %q: %v", tool.Name, err)
  129. }
  130. var optional []string
  131. for name := range schema.Properties {
  132. if !slices.Contains(schema.Required, name) {
  133. optional = append(optional, name)
  134. }
  135. }
  136. if got, want := sortedClone(schema.Required), sortedClone(want.required); !slices.Equal(got, want) {
  137. t.Errorf("tool %q required args = %v, want %v", tool.Name, got, want)
  138. }
  139. if got, want := sortedClone(optional), sortedClone(want.optional); !slices.Equal(got, want) {
  140. t.Errorf("tool %q optional args = %v, want %v", tool.Name, got, want)
  141. }
  142. }
  143. for name := range toolArgContract {
  144. if !seen[name] {
  145. t.Errorf("tool %q is in the contract but not listed by the server", name)
  146. }
  147. }
  148. }
  149. // validateStructuredContent re-marshals the declared output schema, resolves
  150. // it, and validates a real structuredContent against it — the same
  151. // self-consistency check a strict client performs.
  152. func validateStructuredContent(t *testing.T, tool *mcp.Tool, structured any) {
  153. t.Helper()
  154. raw, err := json.Marshal(tool.OutputSchema)
  155. if err != nil {
  156. t.Fatalf("marshal outputSchema of %q: %v", tool.Name, err)
  157. }
  158. var schema jsonschema.Schema
  159. if err := json.Unmarshal(raw, &schema); err != nil {
  160. t.Fatalf("unmarshal outputSchema of %q: %v", tool.Name, err)
  161. }
  162. resolved, err := schema.Resolve(nil)
  163. if err != nil {
  164. t.Fatalf("resolve outputSchema of %q: %v", tool.Name, err)
  165. }
  166. if err := resolved.Validate(structured); err != nil {
  167. t.Errorf("structuredContent of %q does not match its declared outputSchema: %v", tool.Name, err)
  168. }
  169. }
  170. func findTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool {
  171. t.Helper()
  172. for _, tool := range tools {
  173. if tool.Name == name {
  174. return tool
  175. }
  176. }
  177. t.Fatalf("tool %q not listed", name)
  178. return nil
  179. }
  180. // TestStructuredContentMatchesDeclaredSchema calls every tool that declares an
  181. // outputSchema and validates the returned structuredContent against that
  182. // schema. What we announce in tools/list must be what we deliver in
  183. // tools/call.
  184. func TestStructuredContentMatchesDeclaredSchema(t *testing.T) {
  185. cs, fake := newTestSession(t, true)
  186. tools := listedTools(t, cs)
  187. cases := []struct {
  188. name string
  189. args map[string]any
  190. }{
  191. {name: "search_pages", args: map[string]any{"query": "mcp"}},
  192. {name: "get_all_pages"},
  193. {name: "create_page", args: map[string]any{
  194. "title": "New", "content": "body", "description": "desc", "path": "/new",
  195. }},
  196. {name: "edit_page", args: map[string]any{
  197. "id": 1, "title": "Edit", "content": "body", "description": "desc", "path": "/edit",
  198. }},
  199. }
  200. for _, tc := range cases {
  201. t.Run(tc.name, func(t *testing.T) {
  202. tool := findTool(t, tools, tc.name)
  203. if tool.OutputSchema == nil {
  204. t.Fatalf("tool %q declares no outputSchema", tc.name)
  205. }
  206. res := callTool(t, cs, tc.name, tc.args)
  207. if res.IsError {
  208. t.Fatalf("IsError = true: %s", toolText(t, res))
  209. }
  210. if res.StructuredContent == nil {
  211. t.Fatalf("structuredContent = nil, want a value matching the declared outputSchema")
  212. }
  213. validateStructuredContent(t, tool, res.StructuredContent)
  214. })
  215. }
  216. // get_all_pages with an empty result must deliver {"pages": []}, never a
  217. // bare null: a nil Go slice would serialize as JSON null and violate the
  218. // required "pages" property of the declared schema.
  219. fake.emptyList = true
  220. t.Run("get_all_pages empty", func(t *testing.T) {
  221. tool := findTool(t, tools, "get_all_pages")
  222. res := callTool(t, cs, "get_all_pages", nil)
  223. if res.IsError {
  224. t.Fatalf("IsError = true: %s", toolText(t, res))
  225. }
  226. raw, err := json.Marshal(res.StructuredContent)
  227. if err != nil {
  228. t.Fatalf("marshal structuredContent: %v", err)
  229. }
  230. if got, want := string(raw), `{"pages":[]}`; got != want {
  231. t.Errorf("structuredContent = %s, want %s", got, want)
  232. }
  233. validateStructuredContent(t, tool, res.StructuredContent)
  234. })
  235. }
  236. // TestNullPageHasNoStructuredContent pins the semantic contract for a missing
  237. // page: the text content is exactly "null" and no structuredContent is sent.
  238. // A typed nil page coerced into structured content would surface as {} and
  239. // read like an existing empty page.
  240. func TestNullPageHasNoStructuredContent(t *testing.T) {
  241. cs, _ := newTestSession(t, false)
  242. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404})
  243. if res.IsError {
  244. t.Fatalf("IsError = true: %s", toolText(t, res))
  245. }
  246. if res.StructuredContent != nil {
  247. t.Errorf("structuredContent = %v, want none for a missing page", res.StructuredContent)
  248. }
  249. if got := strings.TrimSpace(toolText(t, res)); got != "null" {
  250. t.Errorf("text = %q, want \"null\"", got)
  251. }
  252. }