feat: llms.txt and llms-full.txt, openapi.json, MCP server
This commit is contained in:
1 parent
daba3c04c0
commit
4cff43fefb
25 files changed
+1691
-1
No files matched your search
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user