feat: llms.txt and llms-full.txt, openapi.json, MCP server

This commit is contained in:
Florian Sylvain committed 2026-10-23 01:29:59 +02:00
1 parent daba3c04c0
commit 4cff43fefb
25 files changed
+1691 -1

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/infrastructure/config"
"net/http"
"os"
"regexp"
"sort"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
var catalogueRoutePatterns = []*regexp.Regexp{
regexp.MustCompile(`\w+\(\s*'[^']+',\s*'([A-Z]+)',\s*'([^']+)'`),
regexp.MustCompile(`row\(\s*'([A-Z]+)',\s*'([^']+)'`),
regexp.MustCompile(`method: '([A-Z]+)',\s*path: '([^']+)'`),
}
func catalogueRoutes(t *testing.T) map[string]bool {
t.Helper()
raw, err := os.ReadFile("../web/src/data/apiCatalogue.ts")
if err != nil {
t.Fatal(err)
}
routes := map[string]bool{}
for _, pattern := range catalogueRoutePatterns {
for _, m := range pattern.FindAllStringSubmatch(string(raw), -1) {
routes[m[1]+" "+strings.TrimSuffix(m[2], "/")] = true
}
}
return routes
}
func routerRoutes(t *testing.T) map[string]bool {
t.Helper()
router := api.InitBackendRoutes(&api.UseCases{}, config.Settings{})
routes := map[string]bool{}
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
route = strings.ReplaceAll(route, "/*", "")
routes[method+" /v1"+strings.TrimSuffix(route, "/")] = true
return nil
})
if err != nil {
t.Fatal(err)
}
return routes
}
var routesNotYetInTheCatalogue = []string{
"DELETE /v1/content-type/{key}",
"DELETE /v1/content-type/{key}/entry/{id}",
"DELETE /v1/image-variants/{name}",
"DELETE /v1/media/{id}/variants/{name}",
"GET /v1/builtin-type",
"GET /v1/builtin-type/{key}",
"GET /v1/content-type",
"GET /v1/content-type/{key}",
"GET /v1/content-type/{key}/entry",
"GET /v1/content-type/{key}/entry/{id}",
"GET /v1/image-variants",
"GET /v1/image-variants/cache",
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
"POST /v1/image-variants",
"POST /v1/image-variants/cache/purge",
"POST /v1/image-variants/{name}/purge",
"POST /v1/media/{id}/variants",
"POST /v1/media/{id}/variants/{name}",
"PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}",
}
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
documented := catalogueRoutes(t)
exempt := map[string]bool{}
for _, route := range routesNotYetInTheCatalogue {
exempt[route] = true
}
actual := routerRoutes(t)
var missing, stale []string
for route := range actual {
if !documented[route] && !exempt[route] {
missing = append(missing, route)
}
}
for route := range exempt {
if documented[route] || !actual[route] {
stale = append(stale, route)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 {
t.Errorf("routes missing from web/src/data/apiCatalogue.ts:\n%s", strings.Join(missing, "\n"))
}
if len(stale) > 0 {
t.Errorf("routes to remove from routesNotYetInTheCatalogue:\n%s", strings.Join(stale, "\n"))
}
}
func TestCatalogueRoutesExistInTheRouter(t *testing.T) {
actual := routerRoutes(t)
var unknown []string
for route := range catalogueRoutes(t) {
if strings.Contains(route, " /v1") && !actual[route] {
unknown = append(unknown, route)
}
}
sort.Strings(unknown)
if len(unknown) > 0 {
t.Errorf("catalogue routes the router does not serve:\n%s", strings.Join(unknown, "\n"))
}
}
+76
View File
@@ -0,0 +1,76 @@
package test
import (
"RenewCMS/api/handlers/llms"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
)
func llmsRouter(docs fstest.MapFS) http.Handler {
r := chi.NewRouter()
llms.NewHandler(docs, "v9.9.9", "https://cms.example/").Register(r)
return r
}
func llmsGet(handler http.Handler, target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
func TestLlmsServesGeneratedDocsWithVersionAndBaseURL(t *testing.T) {
handler := llmsRouter(fstest.MapFS{
"llms.txt": {Data: []byte("# RenewCMS {{VERSION}}\n{{BASE_URL}}/llms-full.txt")},
"llms-full.txt": {Data: []byte("full {{VERSION}}")},
"openapi.json": {Data: []byte(`{"version":"{{VERSION}}","servers":[{"url":"{{BASE_URL}}"}]}`)},
"llms/gotchas.md": {Data: []byte("# Gotchas {{VERSION}}")},
"llms/private.txt": {Data: []byte("not a page")},
})
cases := []struct {
target string
contentType string
body string
}{
{"/llms.txt", "text/plain; charset=utf-8", "# RenewCMS v9.9.9\nhttps://cms.example/llms-full.txt"},
{"/llms-full.txt", "text/plain; charset=utf-8", "full v9.9.9"},
{"/openapi.json", "application/json", `{"version":"v9.9.9","servers":[{"url":"https://cms.example"}]}`},
{"/llms/gotchas.md", "text/markdown; charset=utf-8", "# Gotchas v9.9.9"},
}
for _, tc := range cases {
rec := llmsGet(handler, tc.target)
if rec.Code != http.StatusOK {
t.Fatalf("%s answered %d", tc.target, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != tc.contentType {
t.Errorf("%s content type %q, want %q", tc.target, got, tc.contentType)
}
if rec.Body.String() != tc.body {
t.Errorf("%s body %q, want %q", tc.target, rec.Body.String(), tc.body)
}
}
}
func TestLlmsAnswers404WhenNothingWasGenerated(t *testing.T) {
handler := llmsRouter(fstest.MapFS{})
for _, target := range []string{"/llms.txt", "/llms-full.txt", "/openapi.json", "/llms/gotchas.md", "/llms/private.txt", "/llms/..%2Fllms.txt"} {
rec := llmsGet(handler, target)
if rec.Code != http.StatusNotFound {
t.Errorf("%s answered %d, want 404", target, rec.Code)
}
if !strings.Contains(rec.Body.String(), "task build:llms") {
t.Errorf("%s body %q does not name the task", target, rec.Body.String())
}
}
}
func TestLlmsOnlyServesMarkdownPages(t *testing.T) {
handler := llmsRouter(fstest.MapFS{"llms/private.txt": {Data: []byte("not a page")}})
if rec := llmsGet(handler, "/llms/private.txt"); rec.Code != http.StatusNotFound {
t.Fatalf("answered %d, want 404", rec.Code)
}
}
+189
View File
@@ -0,0 +1,189 @@
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": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
{"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)
}