| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515 |
- package server
- import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "slices"
- "strings"
- "sync"
- "testing"
- "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
- "github.com/modelcontextprotocol/go-sdk/mcp"
- )
- type capturedRequest struct {
- Query string `json:"query"`
- Variables map[string]any `json:"variables"`
- }
- type fakeWiki struct {
- t *testing.T
- mu sync.Mutex
- requests []capturedRequest
- emptyList bool // GetAllPages returns an empty list
- }
- func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
- f.t.Helper()
- if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
- f.t.Errorf("Authorization = %q, want %q", got, want)
- }
- var req capturedRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- f.t.Errorf("decode request: %v", err)
- http.Error(w, "bad request", http.StatusBadRequest)
- return
- }
- f.mu.Lock()
- f.requests = append(f.requests, req)
- f.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- switch {
- case strings.Contains(req.Query, "query SearchPages"):
- fmt.Fprint(w, `{"data":{"pages":{"search":{"results":[
- {"title":"MCP","description":"protocol","path":"/mcp","locale":"en"}
- ],"suggestions":["mcp"],"totalHits":1}}}}`)
- case strings.Contains(req.Query, "query GetPageById"):
- id, _ := req.Variables["id"].(float64)
- if id == 404 {
- fmt.Fprint(w, `{"data":{"pages":{"single":null}}}`)
- return
- }
- fmt.Fprintf(w, `{"data":{"pages":{"single":{
- "id":%d,"path":"/dev/go","title":"Go","description":"desc",
- "tags":[{"tag":"go","title":"Go"}],"content":"body",
- "createdAt":"c","updatedAt":"u","locale":"de"}}}}`, int(id))
- case strings.Contains(req.Query, "query GetPageByPath"):
- path, _ := req.Variables["path"].(string)
- locale, _ := req.Variables["locale"].(string)
- fmt.Fprintf(w, `{"data":{"pages":{"singleByPath":{
- "id":7,"path":%q,"title":"Start","description":"desc",
- "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":%q}}}}`, path, locale)
- case strings.Contains(req.Query, "query GetAllPages"):
- if f.emptyList {
- fmt.Fprint(w, `{"data":{"pages":{"list":[]}}}`)
- return
- }
- fmt.Fprint(w, `{"data":{"pages":{"list":[
- {"id":1,"path":"/a","locale":"de","title":"A","description":"a",
- "createdAt":"c","updatedAt":"u","tags":["go"]}
- ]}}}`)
- case strings.Contains(req.Query, "mutation CreatePage"):
- title, _ := req.Variables["title"].(string)
- if title == "fail" {
- fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
- "succeeded":false,"errorCode":6001,"slug":"","message":"Nope"}}}}}`)
- return
- }
- fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
- "succeeded":true,"errorCode":0,"slug":"new","message":"Page created"}}}}}`)
- case strings.Contains(req.Query, "mutation UpdatePage"):
- id, _ := req.Variables["id"].(float64)
- if id == 404 {
- fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
- "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
- return
- }
- fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
- "succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`)
- default:
- f.t.Errorf("unexpected query: %s", req.Query)
- http.Error(w, "unknown operation", http.StatusBadRequest)
- }
- }
- func (f *fakeWiki) lastContaining(t *testing.T, fragment string) capturedRequest {
- t.Helper()
- f.mu.Lock()
- defer f.mu.Unlock()
- for i := len(f.requests) - 1; i >= 0; i-- {
- if strings.Contains(f.requests[i].Query, fragment) {
- return f.requests[i]
- }
- }
- t.Fatalf("no request containing %q", fragment)
- return capturedRequest{}
- }
- func newTestSession(t *testing.T, enableEdit bool) (*mcp.ClientSession, *fakeWiki) {
- t.Helper()
- fake := &fakeWiki{t: t}
- wikiSrv := httptest.NewServer(http.HandlerFunc(fake.handle))
- t.Cleanup(wikiSrv.Close)
- wikiClient, err := wikijs.New(wikiSrv.URL, "test-key")
- if err != nil {
- t.Fatalf("wikijs.New() error = %v", err)
- }
- srv := New(wikiClient, Options{
- Version: "test",
- EnableEdit: enableEdit,
- Logger: slog.New(slog.DiscardHandler),
- })
- st, ct := mcp.NewInMemoryTransports()
- ctx := context.Background()
- ss, err := srv.Connect(ctx, st, nil)
- if err != nil {
- t.Fatalf("server connect: %v", err)
- }
- t.Cleanup(func() { _ = ss.Close() })
- client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
- cs, err := client.Connect(ctx, ct, nil)
- if err != nil {
- t.Fatalf("client connect: %v", err)
- }
- t.Cleanup(func() { _ = cs.Close() })
- return cs, fake
- }
- func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
- t.Helper()
- res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
- if err != nil {
- t.Fatalf("CallTool(%s) error = %v", name, err)
- }
- return res
- }
- func toolText(t *testing.T, res *mcp.CallToolResult) string {
- t.Helper()
- if len(res.Content) == 0 {
- t.Fatal("tool result has no content")
- }
- text, ok := res.Content[0].(*mcp.TextContent)
- if !ok {
- t.Fatalf("content type = %T, want *mcp.TextContent", res.Content[0])
- }
- return text.Text
- }
- func TestToolListWithoutEdit(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res, err := cs.ListTools(context.Background(), nil)
- if err != nil {
- t.Fatalf("ListTools() error = %v", err)
- }
- got := toolNames(res.Tools)
- want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages"}
- if !equalNames(got, want) {
- t.Errorf("tools = %v, want %v", got, want)
- }
- }
- func TestToolListWithEdit(t *testing.T) {
- cs, _ := newTestSession(t, true)
- res, err := cs.ListTools(context.Background(), nil)
- if err != nil {
- t.Fatalf("ListTools() error = %v", err)
- }
- got := toolNames(res.Tools)
- want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "create_page", "edit_page"}
- if !equalNames(got, want) {
- t.Errorf("tools = %v, want %v", got, want)
- }
- }
- func toolNames(tools []*mcp.Tool) []string {
- names := make([]string, len(tools))
- for i, tool := range tools {
- names[i] = tool.Name
- }
- return names
- }
- func equalNames(got, want []string) bool {
- got = slices.Clone(got)
- want = slices.Clone(want)
- slices.Sort(got)
- slices.Sort(want)
- return slices.Equal(got, want)
- }
- func TestSearchPagesTool(t *testing.T) {
- cs, fake := newTestSession(t, false)
- res := callTool(t, cs, "search_pages", map[string]any{"query": "mcp"})
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- if res.StructuredContent == nil {
- t.Error("StructuredContent = nil")
- }
- var out wikijs.SearchResults
- if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
- t.Fatalf("decode result: %v", err)
- }
- if out.TotalHits != 1 || len(out.Results) != 1 {
- t.Fatalf("result = %+v", out)
- }
- if !strings.HasSuffix(out.Results[0].URL, "/mcp") {
- t.Errorf("URL = %q", out.Results[0].URL)
- }
- req := fake.lastContaining(t, "query SearchPages")
- if _, ok := req.Variables["path"]; ok {
- t.Errorf("path variable present: %v", req.Variables["path"])
- }
- }
- func TestSearchPagesValidation(t *testing.T) {
- cs, _ := newTestSession(t, false)
- tests := []struct {
- name string
- args map[string]any
- want string
- }{
- {name: "empty query", args: map[string]any{"query": " "}, want: "query must not be empty"},
- {name: "bad locale", args: map[string]any{"query": "x", "locale": "english"}, want: "invalid locale"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- res := callTool(t, cs, "search_pages", tt.args)
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- if got := toolText(t, res); !strings.Contains(got, tt.want) {
- t.Errorf("text = %q, want substring %q", got, tt.want)
- }
- })
- }
- }
- func TestGetPageByIDTool(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 42})
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- var page wikijs.Page
- if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
- t.Fatalf("decode page: %v", err)
- }
- if page.ID != 42 || page.Title != "Go" {
- t.Errorf("page = %+v", page)
- }
- if !strings.HasSuffix(page.URL, "/de/dev/go") {
- t.Errorf("URL = %q", page.URL)
- }
- }
- func TestGetPageByIDNotFound(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 got := strings.TrimSpace(toolText(t, res)); got != "null" {
- t.Errorf("text = %q, want null", got)
- }
- }
- func TestGetPageByIDInvalid(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 0})
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- if got := toolText(t, res); !strings.Contains(got, "positive") {
- t.Errorf("text = %q", got)
- }
- }
- func TestGetPageByPathTool(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start", "locale": "de-DE"})
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- var page wikijs.Page
- if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
- t.Fatalf("decode page: %v", err)
- }
- if page.ID != 7 {
- t.Errorf("page = %+v", page)
- }
- if !strings.HasSuffix(page.URL, "/de-DE/start") {
- t.Errorf("URL = %q", page.URL)
- }
- }
- func TestGetPageByPathRequiresLocale(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start"})
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- }
- func TestListPagesTool(t *testing.T) {
- cs, fake := newTestSession(t, false)
- res := callTool(t, cs, "get_all_pages", map[string]any{
- "limit": 10,
- "locale": "de",
- "tags": []string{"go"},
- })
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- var out struct {
- Pages []wikijs.PageListItem `json:"pages"`
- }
- if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
- t.Fatalf("decode pages: %v", err)
- }
- if len(out.Pages) != 1 || out.Pages[0].ID != 1 {
- t.Errorf("pages = %+v", out.Pages)
- }
- req := fake.lastContaining(t, "query GetAllPages")
- if got := req.Variables["limit"]; got != float64(10) {
- t.Errorf("variables[limit] = %v", got)
- }
- if got := req.Variables["locale"]; got != "de" {
- t.Errorf("variables[locale] = %v", got)
- }
- }
- func TestListPagesValidation(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_all_pages", map[string]any{"limit": 0})
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- if got := toolText(t, res); !strings.Contains(got, "limit must be a positive integer") {
- t.Errorf("text = %q", got)
- }
- }
- func TestCreatePageDefaults(t *testing.T) {
- cs, fake := newTestSession(t, true)
- res := callTool(t, cs, "create_page", map[string]any{
- "title": "New",
- "content": "body",
- "description": "desc",
- "path": "/new",
- })
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- var out mutationOutput
- if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
- t.Fatalf("decode output: %v", err)
- }
- if !out.Succeeded || out.Message != "Page created" {
- t.Errorf("output = %+v", out)
- }
- req := fake.lastContaining(t, "mutation CreatePage")
- want := map[string]any{
- "editor": "markdown",
- "isPublished": true,
- "isPrivate": false,
- "locale": "en",
- }
- for key, value := range want {
- if got := req.Variables[key]; got != value {
- t.Errorf("variables[%s] = %v, want %v", key, got, value)
- }
- }
- tags, ok := req.Variables["tags"].([]any)
- if !ok || len(tags) != 0 {
- t.Errorf("variables[tags] = %v, want []", req.Variables["tags"])
- }
- }
- func TestCreatePageFailure(t *testing.T) {
- cs, _ := newTestSession(t, true)
- res := callTool(t, cs, "create_page", map[string]any{
- "title": "fail",
- "content": "body",
- "description": "desc",
- "path": "/new",
- })
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- text := toolText(t, res)
- if !strings.Contains(text, "failed to create page") || !strings.Contains(text, "Nope") || !strings.Contains(text, "6001") {
- t.Errorf("text = %q", text)
- }
- }
- func TestEditPageDefaults(t *testing.T) {
- cs, fake := newTestSession(t, true)
- res := callTool(t, cs, "edit_page", map[string]any{
- "id": 7,
- "title": "Updated",
- "content": "body",
- "description": "desc",
- "path": "/start",
- })
- if res.IsError {
- t.Fatalf("IsError = true: %s", toolText(t, res))
- }
- var out mutationOutput
- if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
- t.Fatalf("decode output: %v", err)
- }
- if !out.Succeeded || out.Message != "Page updated" {
- t.Errorf("output = %+v", out)
- }
- req := fake.lastContaining(t, "mutation UpdatePage")
- if got := req.Variables["id"]; got != float64(7) {
- t.Errorf("variables[id] = %v", got)
- }
- if got := req.Variables["editor"]; got != "markdown" {
- t.Errorf("variables[editor] = %v", got)
- }
- }
- func TestEditPageNotFound(t *testing.T) {
- cs, _ := newTestSession(t, true)
- res := callTool(t, cs, "edit_page", map[string]any{
- "id": 404,
- "title": "Updated",
- "content": "body",
- "description": "desc",
- "path": "/start",
- })
- if !res.IsError {
- t.Fatal("IsError = false, want true")
- }
- text := toolText(t, res)
- if !strings.Contains(text, "failed to update page") || !strings.Contains(text, "Page not found") {
- t.Errorf("text = %q", text)
- }
- }
- func TestEditToolsHiddenWithoutEditMode(t *testing.T) {
- cs, _ := newTestSession(t, false)
- _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
- Name: "create_page",
- Arguments: map[string]any{"title": "x", "content": "", "description": "", "path": "/x"},
- })
- if err == nil {
- t.Fatal("CallTool(create_page) error = nil, want tool not found")
- }
- }
- func TestToolOutputIsJSON(t *testing.T) {
- cs, _ := newTestSession(t, false)
- res := callTool(t, cs, "get_all_pages", nil)
- var decoded any
- if err := json.Unmarshal([]byte(toolText(t, res)), &decoded); err != nil {
- t.Fatalf("output is not JSON: %v", err)
- }
- }
|