contract_test.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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. "list_asset_folders": {optional: []string{"parentFolder"}},
  91. "list_assets": {optional: []string{"folder", "kind"}},
  92. "create_asset_folder": {
  93. required: []string{"name"},
  94. optional: []string{"parentFolder"},
  95. },
  96. "upload_asset": {
  97. required: []string{"filename"},
  98. optional: []string{"content", "contentB64", "folder", "mimeType"},
  99. },
  100. "create_page": {
  101. required: []string{"content", "description", "path", "title"},
  102. optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"},
  103. },
  104. "edit_page": {
  105. required: []string{"content", "description", "id", "path", "title"},
  106. optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"},
  107. },
  108. }
  109. func sortedClone(values []string) []string {
  110. out := slices.Clone(values)
  111. slices.Sort(out)
  112. return out
  113. }
  114. // TestToolArgumentContract pins tool names and argument names (including
  115. // camelCase such as isPublished) plus the required/optional split of every
  116. // tool. A mismatch means the public API changed and README.md,
  117. // AGENTS.md, and every client integration need a conscious update.
  118. func TestToolArgumentContract(t *testing.T) {
  119. cs, _ := newTestSession(t, true)
  120. tools := listedTools(t, cs)
  121. seen := map[string]bool{}
  122. for _, tool := range tools {
  123. seen[tool.Name] = true
  124. want, ok := toolArgContract[tool.Name]
  125. if !ok {
  126. t.Errorf("unexpected tool %q: not in the argument contract; update toolArgContract", tool.Name)
  127. continue
  128. }
  129. raw, err := json.Marshal(tool.InputSchema)
  130. if err != nil {
  131. t.Fatalf("marshal inputSchema of %q: %v", tool.Name, err)
  132. }
  133. var schema struct {
  134. Properties map[string]json.RawMessage `json:"properties"`
  135. Required []string `json:"required"`
  136. }
  137. if err := json.Unmarshal(raw, &schema); err != nil {
  138. t.Fatalf("unmarshal inputSchema of %q: %v", tool.Name, err)
  139. }
  140. var optional []string
  141. for name := range schema.Properties {
  142. if !slices.Contains(schema.Required, name) {
  143. optional = append(optional, name)
  144. }
  145. }
  146. if got, want := sortedClone(schema.Required), sortedClone(want.required); !slices.Equal(got, want) {
  147. t.Errorf("tool %q required args = %v, want %v", tool.Name, got, want)
  148. }
  149. if got, want := sortedClone(optional), sortedClone(want.optional); !slices.Equal(got, want) {
  150. t.Errorf("tool %q optional args = %v, want %v", tool.Name, got, want)
  151. }
  152. }
  153. for name := range toolArgContract {
  154. if !seen[name] {
  155. t.Errorf("tool %q is in the contract but not listed by the server", name)
  156. }
  157. }
  158. }
  159. // validateStructuredContent re-marshals the declared output schema, resolves
  160. // it, and validates a real structuredContent against it — the same
  161. // self-consistency check a strict client performs.
  162. func validateStructuredContent(t *testing.T, tool *mcp.Tool, structured any) {
  163. t.Helper()
  164. raw, err := json.Marshal(tool.OutputSchema)
  165. if err != nil {
  166. t.Fatalf("marshal outputSchema of %q: %v", tool.Name, err)
  167. }
  168. var schema jsonschema.Schema
  169. if err := json.Unmarshal(raw, &schema); err != nil {
  170. t.Fatalf("unmarshal outputSchema of %q: %v", tool.Name, err)
  171. }
  172. resolved, err := schema.Resolve(nil)
  173. if err != nil {
  174. t.Fatalf("resolve outputSchema of %q: %v", tool.Name, err)
  175. }
  176. if err := resolved.Validate(structured); err != nil {
  177. t.Errorf("structuredContent of %q does not match its declared outputSchema: %v", tool.Name, err)
  178. }
  179. }
  180. func findTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool {
  181. t.Helper()
  182. for _, tool := range tools {
  183. if tool.Name == name {
  184. return tool
  185. }
  186. }
  187. t.Fatalf("tool %q not listed", name)
  188. return nil
  189. }
  190. // TestStructuredContentMatchesDeclaredSchema calls every tool that declares an
  191. // outputSchema and validates the returned structuredContent against that
  192. // schema. What we announce in tools/list must be what we deliver in
  193. // tools/call.
  194. func TestStructuredContentMatchesDeclaredSchema(t *testing.T) {
  195. cs, fake := newTestSession(t, true)
  196. tools := listedTools(t, cs)
  197. cases := []struct {
  198. name string
  199. args map[string]any
  200. }{
  201. {name: "search_pages", args: map[string]any{"query": "mcp"}},
  202. {name: "get_all_pages"},
  203. {name: "create_page", args: map[string]any{
  204. "title": "New", "content": "body", "description": "desc", "path": "/new",
  205. }},
  206. {name: "edit_page", args: map[string]any{
  207. "id": 1, "title": "Edit", "content": "body", "description": "desc", "path": "/edit",
  208. }},
  209. }
  210. for _, tc := range cases {
  211. t.Run(tc.name, func(t *testing.T) {
  212. tool := findTool(t, tools, tc.name)
  213. if tool.OutputSchema == nil {
  214. t.Fatalf("tool %q declares no outputSchema", tc.name)
  215. }
  216. res := callTool(t, cs, tc.name, tc.args)
  217. if res.IsError {
  218. t.Fatalf("IsError = true: %s", toolText(t, res))
  219. }
  220. if res.StructuredContent == nil {
  221. t.Fatalf("structuredContent = nil, want a value matching the declared outputSchema")
  222. }
  223. validateStructuredContent(t, tool, res.StructuredContent)
  224. })
  225. }
  226. // get_all_pages with an empty result must deliver {"pages": []}, never a
  227. // bare null: a nil Go slice would serialize as JSON null and violate the
  228. // required "pages" property of the declared schema.
  229. fake.emptyList = true
  230. t.Run("get_all_pages empty", func(t *testing.T) {
  231. tool := findTool(t, tools, "get_all_pages")
  232. res := callTool(t, cs, "get_all_pages", nil)
  233. if res.IsError {
  234. t.Fatalf("IsError = true: %s", toolText(t, res))
  235. }
  236. raw, err := json.Marshal(res.StructuredContent)
  237. if err != nil {
  238. t.Fatalf("marshal structuredContent: %v", err)
  239. }
  240. if got, want := string(raw), `{"pages":[]}`; got != want {
  241. t.Errorf("structuredContent = %s, want %s", got, want)
  242. }
  243. validateStructuredContent(t, tool, res.StructuredContent)
  244. })
  245. }
  246. // TestNullPageHasNoStructuredContent pins the semantic contract for a missing
  247. // page: the text content is exactly "null" and no structuredContent is sent.
  248. // A typed nil page coerced into structured content would surface as {} and
  249. // read like an existing empty page.
  250. func TestNullPageHasNoStructuredContent(t *testing.T) {
  251. cs, _ := newTestSession(t, false)
  252. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404})
  253. if res.IsError {
  254. t.Fatalf("IsError = true: %s", toolText(t, res))
  255. }
  256. if res.StructuredContent != nil {
  257. t.Errorf("structuredContent = %v, want none for a missing page", res.StructuredContent)
  258. }
  259. if got := strings.TrimSpace(toolText(t, res)); got != "null" {
  260. t.Errorf("text = %q, want \"null\"", got)
  261. }
  262. }