tools_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "slices"
  10. "strings"
  11. "sync"
  12. "testing"
  13. "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
  14. "github.com/modelcontextprotocol/go-sdk/mcp"
  15. )
  16. type capturedRequest struct {
  17. Query string `json:"query"`
  18. Variables map[string]any `json:"variables"`
  19. }
  20. type fakeWiki struct {
  21. t *testing.T
  22. mu sync.Mutex
  23. requests []capturedRequest
  24. }
  25. func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
  26. f.t.Helper()
  27. if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
  28. f.t.Errorf("Authorization = %q, want %q", got, want)
  29. }
  30. var req capturedRequest
  31. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  32. f.t.Errorf("decode request: %v", err)
  33. http.Error(w, "bad request", http.StatusBadRequest)
  34. return
  35. }
  36. f.mu.Lock()
  37. f.requests = append(f.requests, req)
  38. f.mu.Unlock()
  39. w.Header().Set("Content-Type", "application/json")
  40. switch {
  41. case strings.Contains(req.Query, "query SearchPages"):
  42. fmt.Fprint(w, `{"data":{"pages":{"search":{"results":[
  43. {"title":"MCP","description":"protocol","path":"/mcp","locale":"en"}
  44. ],"suggestions":["mcp"],"totalHits":1}}}}`)
  45. case strings.Contains(req.Query, "query GetPageById"):
  46. id, _ := req.Variables["id"].(float64)
  47. if id == 404 {
  48. fmt.Fprint(w, `{"data":{"pages":{"single":null}}}`)
  49. return
  50. }
  51. fmt.Fprintf(w, `{"data":{"pages":{"single":{
  52. "id":%d,"path":"/dev/go","title":"Go","description":"desc",
  53. "tags":[{"tag":"go","title":"Go"}],"content":"body",
  54. "createdAt":"c","updatedAt":"u","locale":"de"}}}}`, int(id))
  55. case strings.Contains(req.Query, "query GetPageByPath"):
  56. path, _ := req.Variables["path"].(string)
  57. locale, _ := req.Variables["locale"].(string)
  58. fmt.Fprintf(w, `{"data":{"pages":{"singleByPath":{
  59. "id":7,"path":%q,"title":"Start","description":"desc",
  60. "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":%q}}}}`, path, locale)
  61. case strings.Contains(req.Query, "query GetAllPages"):
  62. fmt.Fprint(w, `{"data":{"pages":{"list":[
  63. {"id":1,"path":"/a","locale":"de","title":"A","description":"a",
  64. "createdAt":"c","updatedAt":"u","tags":["go"]}
  65. ]}}}`)
  66. case strings.Contains(req.Query, "mutation CreatePage"):
  67. title, _ := req.Variables["title"].(string)
  68. if title == "fail" {
  69. fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
  70. "succeeded":false,"errorCode":6001,"slug":"","message":"Nope"}}}}}`)
  71. return
  72. }
  73. fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
  74. "succeeded":true,"errorCode":0,"slug":"new","message":"Page created"}}}}}`)
  75. case strings.Contains(req.Query, "mutation UpdatePage"):
  76. id, _ := req.Variables["id"].(float64)
  77. if id == 404 {
  78. fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
  79. "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
  80. return
  81. }
  82. fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
  83. "succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`)
  84. default:
  85. f.t.Errorf("unexpected query: %s", req.Query)
  86. http.Error(w, "unknown operation", http.StatusBadRequest)
  87. }
  88. }
  89. func (f *fakeWiki) lastContaining(t *testing.T, fragment string) capturedRequest {
  90. t.Helper()
  91. f.mu.Lock()
  92. defer f.mu.Unlock()
  93. for i := len(f.requests) - 1; i >= 0; i-- {
  94. if strings.Contains(f.requests[i].Query, fragment) {
  95. return f.requests[i]
  96. }
  97. }
  98. t.Fatalf("no request containing %q", fragment)
  99. return capturedRequest{}
  100. }
  101. func newTestSession(t *testing.T, enableEdit bool) (*mcp.ClientSession, *fakeWiki) {
  102. t.Helper()
  103. fake := &fakeWiki{t: t}
  104. wikiSrv := httptest.NewServer(http.HandlerFunc(fake.handle))
  105. t.Cleanup(wikiSrv.Close)
  106. wikiClient, err := wikijs.New(wikiSrv.URL, "test-key")
  107. if err != nil {
  108. t.Fatalf("wikijs.New() error = %v", err)
  109. }
  110. srv := New(wikiClient, Options{
  111. Version: "test",
  112. EnableEdit: enableEdit,
  113. Logger: slog.New(slog.DiscardHandler),
  114. })
  115. st, ct := mcp.NewInMemoryTransports()
  116. ctx := context.Background()
  117. ss, err := srv.Connect(ctx, st, nil)
  118. if err != nil {
  119. t.Fatalf("server connect: %v", err)
  120. }
  121. t.Cleanup(func() { _ = ss.Close() })
  122. client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
  123. cs, err := client.Connect(ctx, ct, nil)
  124. if err != nil {
  125. t.Fatalf("client connect: %v", err)
  126. }
  127. t.Cleanup(func() { _ = cs.Close() })
  128. return cs, fake
  129. }
  130. func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
  131. t.Helper()
  132. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
  133. if err != nil {
  134. t.Fatalf("CallTool(%s) error = %v", name, err)
  135. }
  136. return res
  137. }
  138. func toolText(t *testing.T, res *mcp.CallToolResult) string {
  139. t.Helper()
  140. if len(res.Content) == 0 {
  141. t.Fatal("tool result has no content")
  142. }
  143. text, ok := res.Content[0].(*mcp.TextContent)
  144. if !ok {
  145. t.Fatalf("content type = %T, want *mcp.TextContent", res.Content[0])
  146. }
  147. return text.Text
  148. }
  149. func TestToolListWithoutEdit(t *testing.T) {
  150. cs, _ := newTestSession(t, false)
  151. res, err := cs.ListTools(context.Background(), nil)
  152. if err != nil {
  153. t.Fatalf("ListTools() error = %v", err)
  154. }
  155. got := toolNames(res.Tools)
  156. want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages"}
  157. if !equalNames(got, want) {
  158. t.Errorf("tools = %v, want %v", got, want)
  159. }
  160. }
  161. // TestListToolsOutputSchemasAreObjects guards against the MCP spec violation
  162. // that made strict clients (for example pydantic-based ones) reject the whole
  163. // tools/list response: a tool whose outputSchema.type is not the string
  164. // "object" is invalid whenever an output schema is declared. The check runs
  165. // against the wire format, which is what strict clients validate.
  166. func TestListToolsOutputSchemasAreObjects(t *testing.T) {
  167. cs, _ := newTestSession(t, true)
  168. res, err := cs.ListTools(context.Background(), nil)
  169. if err != nil {
  170. t.Fatalf("ListTools() error = %v", err)
  171. }
  172. if len(res.Tools) == 0 {
  173. t.Fatal("no tools listed")
  174. }
  175. for _, tool := range res.Tools {
  176. if tool.OutputSchema == nil {
  177. continue // optional per the MCP spec
  178. }
  179. raw, err := json.Marshal(tool.OutputSchema)
  180. if err != nil {
  181. t.Fatalf("marshal outputSchema of %q: %v", tool.Name, err)
  182. }
  183. var schema struct {
  184. Type json.RawMessage `json:"type"`
  185. }
  186. if err := json.Unmarshal(raw, &schema); err != nil {
  187. t.Fatalf("unmarshal outputSchema of %q: %v", tool.Name, err)
  188. }
  189. if string(schema.Type) != `"object"` {
  190. t.Errorf("tool %q outputSchema.type = %s, want \"object\"", tool.Name, schema.Type)
  191. }
  192. }
  193. }
  194. func TestToolListWithEdit(t *testing.T) {
  195. cs, _ := newTestSession(t, true)
  196. res, err := cs.ListTools(context.Background(), nil)
  197. if err != nil {
  198. t.Fatalf("ListTools() error = %v", err)
  199. }
  200. got := toolNames(res.Tools)
  201. want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "create_page", "edit_page"}
  202. if !equalNames(got, want) {
  203. t.Errorf("tools = %v, want %v", got, want)
  204. }
  205. }
  206. func toolNames(tools []*mcp.Tool) []string {
  207. names := make([]string, len(tools))
  208. for i, tool := range tools {
  209. names[i] = tool.Name
  210. }
  211. return names
  212. }
  213. func equalNames(got, want []string) bool {
  214. got = slices.Clone(got)
  215. want = slices.Clone(want)
  216. slices.Sort(got)
  217. slices.Sort(want)
  218. return slices.Equal(got, want)
  219. }
  220. func TestSearchPagesTool(t *testing.T) {
  221. cs, fake := newTestSession(t, false)
  222. res := callTool(t, cs, "search_pages", map[string]any{"query": "mcp"})
  223. if res.IsError {
  224. t.Fatalf("IsError = true: %s", toolText(t, res))
  225. }
  226. if res.StructuredContent == nil {
  227. t.Error("StructuredContent = nil")
  228. }
  229. var out wikijs.SearchResults
  230. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  231. t.Fatalf("decode result: %v", err)
  232. }
  233. if out.TotalHits != 1 || len(out.Results) != 1 {
  234. t.Fatalf("result = %+v", out)
  235. }
  236. if !strings.HasSuffix(out.Results[0].URL, "/mcp") {
  237. t.Errorf("URL = %q", out.Results[0].URL)
  238. }
  239. req := fake.lastContaining(t, "query SearchPages")
  240. if _, ok := req.Variables["path"]; ok {
  241. t.Errorf("path variable present: %v", req.Variables["path"])
  242. }
  243. }
  244. func TestSearchPagesValidation(t *testing.T) {
  245. cs, _ := newTestSession(t, false)
  246. tests := []struct {
  247. name string
  248. args map[string]any
  249. want string
  250. }{
  251. {name: "empty query", args: map[string]any{"query": " "}, want: "query must not be empty"},
  252. {name: "bad locale", args: map[string]any{"query": "x", "locale": "english"}, want: "invalid locale"},
  253. }
  254. for _, tt := range tests {
  255. t.Run(tt.name, func(t *testing.T) {
  256. res := callTool(t, cs, "search_pages", tt.args)
  257. if !res.IsError {
  258. t.Fatal("IsError = false, want true")
  259. }
  260. if got := toolText(t, res); !strings.Contains(got, tt.want) {
  261. t.Errorf("text = %q, want substring %q", got, tt.want)
  262. }
  263. })
  264. }
  265. }
  266. func TestGetPageByIDTool(t *testing.T) {
  267. cs, _ := newTestSession(t, false)
  268. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 42})
  269. if res.IsError {
  270. t.Fatalf("IsError = true: %s", toolText(t, res))
  271. }
  272. var page wikijs.Page
  273. if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
  274. t.Fatalf("decode page: %v", err)
  275. }
  276. if page.ID != 42 || page.Title != "Go" {
  277. t.Errorf("page = %+v", page)
  278. }
  279. if !strings.HasSuffix(page.URL, "/de/dev/go") {
  280. t.Errorf("URL = %q", page.URL)
  281. }
  282. }
  283. func TestGetPageByIDNotFound(t *testing.T) {
  284. cs, _ := newTestSession(t, false)
  285. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404})
  286. if res.IsError {
  287. t.Fatalf("IsError = true: %s", toolText(t, res))
  288. }
  289. if got := strings.TrimSpace(toolText(t, res)); got != "null" {
  290. t.Errorf("text = %q, want null", got)
  291. }
  292. }
  293. func TestGetPageByIDInvalid(t *testing.T) {
  294. cs, _ := newTestSession(t, false)
  295. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 0})
  296. if !res.IsError {
  297. t.Fatal("IsError = false, want true")
  298. }
  299. if got := toolText(t, res); !strings.Contains(got, "positive") {
  300. t.Errorf("text = %q", got)
  301. }
  302. }
  303. func TestGetPageByPathTool(t *testing.T) {
  304. cs, _ := newTestSession(t, false)
  305. res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start", "locale": "de-DE"})
  306. if res.IsError {
  307. t.Fatalf("IsError = true: %s", toolText(t, res))
  308. }
  309. var page wikijs.Page
  310. if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
  311. t.Fatalf("decode page: %v", err)
  312. }
  313. if page.ID != 7 {
  314. t.Errorf("page = %+v", page)
  315. }
  316. if !strings.HasSuffix(page.URL, "/de-DE/start") {
  317. t.Errorf("URL = %q", page.URL)
  318. }
  319. }
  320. func TestGetPageByPathRequiresLocale(t *testing.T) {
  321. cs, _ := newTestSession(t, false)
  322. res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start"})
  323. if !res.IsError {
  324. t.Fatal("IsError = false, want true")
  325. }
  326. }
  327. func TestListPagesTool(t *testing.T) {
  328. cs, fake := newTestSession(t, false)
  329. res := callTool(t, cs, "get_all_pages", map[string]any{
  330. "limit": 10,
  331. "locale": "de",
  332. "tags": []string{"go"},
  333. })
  334. if res.IsError {
  335. t.Fatalf("IsError = true: %s", toolText(t, res))
  336. }
  337. var out struct {
  338. Pages []wikijs.PageListItem `json:"pages"`
  339. }
  340. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  341. t.Fatalf("decode pages: %v", err)
  342. }
  343. if len(out.Pages) != 1 || out.Pages[0].ID != 1 {
  344. t.Errorf("pages = %+v", out.Pages)
  345. }
  346. req := fake.lastContaining(t, "query GetAllPages")
  347. if got := req.Variables["limit"]; got != float64(10) {
  348. t.Errorf("variables[limit] = %v", got)
  349. }
  350. if got := req.Variables["locale"]; got != "de" {
  351. t.Errorf("variables[locale] = %v", got)
  352. }
  353. }
  354. func TestListPagesValidation(t *testing.T) {
  355. cs, _ := newTestSession(t, false)
  356. res := callTool(t, cs, "get_all_pages", map[string]any{"limit": 0})
  357. if !res.IsError {
  358. t.Fatal("IsError = false, want true")
  359. }
  360. if got := toolText(t, res); !strings.Contains(got, "limit must be a positive integer") {
  361. t.Errorf("text = %q", got)
  362. }
  363. }
  364. func TestCreatePageDefaults(t *testing.T) {
  365. cs, fake := newTestSession(t, true)
  366. res := callTool(t, cs, "create_page", map[string]any{
  367. "title": "New",
  368. "content": "body",
  369. "description": "desc",
  370. "path": "/new",
  371. })
  372. if res.IsError {
  373. t.Fatalf("IsError = true: %s", toolText(t, res))
  374. }
  375. var out mutationOutput
  376. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  377. t.Fatalf("decode output: %v", err)
  378. }
  379. if !out.Succeeded || out.Message != "Page created" {
  380. t.Errorf("output = %+v", out)
  381. }
  382. req := fake.lastContaining(t, "mutation CreatePage")
  383. want := map[string]any{
  384. "editor": "markdown",
  385. "isPublished": true,
  386. "isPrivate": false,
  387. "locale": "en",
  388. }
  389. for key, value := range want {
  390. if got := req.Variables[key]; got != value {
  391. t.Errorf("variables[%s] = %v, want %v", key, got, value)
  392. }
  393. }
  394. tags, ok := req.Variables["tags"].([]any)
  395. if !ok || len(tags) != 0 {
  396. t.Errorf("variables[tags] = %v, want []", req.Variables["tags"])
  397. }
  398. }
  399. func TestCreatePageFailure(t *testing.T) {
  400. cs, _ := newTestSession(t, true)
  401. res := callTool(t, cs, "create_page", map[string]any{
  402. "title": "fail",
  403. "content": "body",
  404. "description": "desc",
  405. "path": "/new",
  406. })
  407. if !res.IsError {
  408. t.Fatal("IsError = false, want true")
  409. }
  410. text := toolText(t, res)
  411. if !strings.Contains(text, "failed to create page") || !strings.Contains(text, "Nope") || !strings.Contains(text, "6001") {
  412. t.Errorf("text = %q", text)
  413. }
  414. }
  415. func TestEditPageDefaults(t *testing.T) {
  416. cs, fake := newTestSession(t, true)
  417. res := callTool(t, cs, "edit_page", map[string]any{
  418. "id": 7,
  419. "title": "Updated",
  420. "content": "body",
  421. "description": "desc",
  422. "path": "/start",
  423. })
  424. if res.IsError {
  425. t.Fatalf("IsError = true: %s", toolText(t, res))
  426. }
  427. var out mutationOutput
  428. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  429. t.Fatalf("decode output: %v", err)
  430. }
  431. if !out.Succeeded || out.Message != "Page updated" {
  432. t.Errorf("output = %+v", out)
  433. }
  434. req := fake.lastContaining(t, "mutation UpdatePage")
  435. if got := req.Variables["id"]; got != float64(7) {
  436. t.Errorf("variables[id] = %v", got)
  437. }
  438. if got := req.Variables["editor"]; got != "markdown" {
  439. t.Errorf("variables[editor] = %v", got)
  440. }
  441. }
  442. func TestEditPageNotFound(t *testing.T) {
  443. cs, _ := newTestSession(t, true)
  444. res := callTool(t, cs, "edit_page", map[string]any{
  445. "id": 404,
  446. "title": "Updated",
  447. "content": "body",
  448. "description": "desc",
  449. "path": "/start",
  450. })
  451. if !res.IsError {
  452. t.Fatal("IsError = false, want true")
  453. }
  454. text := toolText(t, res)
  455. if !strings.Contains(text, "failed to update page") || !strings.Contains(text, "Page not found") {
  456. t.Errorf("text = %q", text)
  457. }
  458. }
  459. func TestEditToolsHiddenWithoutEditMode(t *testing.T) {
  460. cs, _ := newTestSession(t, false)
  461. _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
  462. Name: "create_page",
  463. Arguments: map[string]any{"title": "x", "content": "", "description": "", "path": "/x"},
  464. })
  465. if err == nil {
  466. t.Fatal("CallTool(create_page) error = nil, want tool not found")
  467. }
  468. }
  469. func TestToolOutputIsJSON(t *testing.T) {
  470. cs, _ := newTestSession(t, false)
  471. res := callTool(t, cs, "get_all_pages", nil)
  472. var decoded any
  473. if err := json.Unmarshal([]byte(toolText(t, res)), &decoded); err != nil {
  474. t.Fatalf("output is not JSON: %v", err)
  475. }
  476. }