package test import ( "RenewCMS/internal/infrastructure/mcpserver" "context" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "reflect" "strings" "sync" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" ) type recordedCall struct { method string target string auth string body string ctype string } type mcpFixture struct { session *mcp.ClientSession mu sync.Mutex calls []recordedCall } func (f *mcpFixture) last() recordedCall { f.mu.Lock() defer f.mu.Unlock() return f.calls[len(f.calls)-1] } func newMCPFixture(t *testing.T, allowDestructive bool, respond func(w http.ResponseWriter, r *http.Request)) *mcpFixture { t.Helper() f := &mcpFixture{} api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) f.mu.Lock() f.calls = append(f.calls, recordedCall{r.Method, r.URL.RequestURI(), r.Header.Get("Authorization"), string(raw), r.Header.Get("Content-Type")}) f.mu.Unlock() respond(w, r) })) t.Cleanup(api.Close) server := mcpserver.New(mcpserver.NewClient(api.URL+"/", "rcms_key", nil), mcpserver.Options{Version: "test", AllowDestructive: allowDestructive}) ctx := context.Background() serverSide, clientSide := mcp.NewInMemoryTransports() if _, err := server.Connect(ctx, serverSide, nil); err != nil { t.Fatal(err) } session, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, clientSide, nil) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = session.Close() }) f.session = session return f } func (f *mcpFixture) callTool(t *testing.T, name string, args map[string]any) *mcp.CallToolResult { t.Helper() res, err := f.session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) if err != nil { t.Fatalf("%s: %v", name, err) } return res } func toolText(res *mcp.CallToolResult) string { return res.Content[0].(*mcp.TextContent).Text } func echoStatus(status int, body string) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) _, _ = io.WriteString(w, body) } } func TestMCPToolsMapToTheAPIWithTheKey(t *testing.T) { f := newMCPFixture(t, false, echoStatus(200, `{"ok":true}`)) cases := []struct { tool string args map[string]any method string target string body string }{ {"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""}, {"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""}, {"create_article", map[string]any{"title": "Grout tips", "body": "

x

"}, "POST", "/v1/article/", `{"body":"

x

","title":"Grout tips"}`}, {"update_article_body", map[string]any{"id": 7, "body": "

y

"}, "PATCH", "/v1/article/7", `{"body":"

y

"}`}, {"publish_article", map[string]any{"id": 7}, "PATCH", "/v1/article/7/publish", ""}, {"check_seo", map[string]any{"id": 7}, "POST", "/v1/article/7/seo/check", `{}`}, {"list_media", map[string]any{"filter": "unused"}, "GET", "/v1/media/?filter=unused", ""}, {"list_sites", map[string]any{}, "GET", "/v1/site/", ""}, {"get_sitemap", map[string]any{"key": "renew-blog", "size": 25}, "GET", "/v1/site/renew-blog/sitemap?size=25", ""}, } for _, tc := range cases { res := f.callTool(t, tc.tool, tc.args) if res.IsError || toolText(res) != `{"ok":true}` { t.Fatalf("%s: error=%v text=%q", tc.tool, res.IsError, toolText(res)) } call := f.last() if call.method != tc.method || call.target != tc.target || !sameJSONBody(call.body, tc.body) || call.auth != "Bearer rcms_key" { t.Errorf("%s sent %q want %q", tc.tool, call.body, tc.body) } } } func TestMCPReportsAPIErrorsAsToolErrors(t *testing.T) { f := newMCPFixture(t, false, echoStatus(403, "This action needs a real session.")) res := f.callTool(t, "list_sites", map[string]any{}) if !res.IsError || toolText(res) != "HTTP 403: This action needs a real session." { t.Fatalf("error=%v text=%q", res.IsError, toolText(res)) } } func TestMCPUploadsMultipartFromBase64(t *testing.T) { f := newMCPFixture(t, false, echoStatus(201, `{"id":1}`)) res := f.callTool(t, "upload_media", map[string]any{"base64": base64.StdEncoding.EncodeToString([]byte("PNGDATA")), "filename": "wall.png"}) if res.IsError { t.Fatal(toolText(res)) } call := f.last() if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") { t.Errorf("sent %+v", call) } if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError { t.Error("an upload without content must fail") } } func TestMCPDestructiveToolsNeedTheFlag(t *testing.T) { names := func(f *mcpFixture) map[string]bool { found := map[string]bool{} for tool, err := range f.session.Tools(context.Background(), nil) { if err != nil { t.Fatal(err) } found[tool.Name] = true } return found } safe := names(newMCPFixture(t, false, echoStatus(200, ""))) if safe["delete_article"] || safe["delete_media"] || !safe["publish_article"] || len(safe) != 10 { t.Errorf("default tools: %v", safe) } f := newMCPFixture(t, true, echoStatus(204, "")) if all := names(f); !all["delete_article"] || !all["delete_media"] { t.Errorf("flagged tools: %v", all) } if res := f.callTool(t, "delete_article", map[string]any{"id": 3}); res.IsError || toolText(res) != "HTTP 204" || f.last().method != "DELETE" { t.Errorf("delete: %q %+v", toolText(res), f.last()) } } func TestMCPServesTheInstanceDocumentationAsAResource(t *testing.T) { f := newMCPFixture(t, false, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/llms-full.txt" { w.WriteHeader(404) return } _, _ = io.WriteString(w, "# RenewCMS documentation") }) res, err := f.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "renewcms://docs/llms-full.txt"}) if err != nil { t.Fatal(err) } if res.Contents[0].Text != "# RenewCMS documentation" { t.Errorf("got %q", res.Contents[0].Text) } } func sameJSONBody(got string, want string) bool { if got == want { return true } var a, b any if json.Unmarshal([]byte(got), &a) != nil || json.Unmarshal([]byte(want), &b) != nil { return false } return reflect.DeepEqual(a, b) }