diff --git a/.gitignore b/.gitignore index 1bd6f6e..0036855 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ CLAUDE.md AGENTS.md codex-workflow.md CODING_STANDARDS.md + +# Generated LLM docs +internal/infrastructure/llmdocs/generated/* +!internal/infrastructure/llmdocs/generated/.gitkeep diff --git a/Dockerfile b/Dockerfile index 860e77b..d9a2bf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ COPY web/package.json web/bun.lock ./ RUN bun install --frozen-lockfile COPY web/ ./ RUN bun run build-only +WORKDIR /src +COPY docs/ ./docs/ +COPY scripts/generate-llms.ts ./scripts/generate-llms.ts +RUN bun scripts/generate-llms.ts FROM golang:1.27.3-alpine AS server ARG VERSION=dev @@ -13,6 +17,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist +COPY --from=web /src/internal/infrastructure/llmdocs/generated ./internal/infrastructure/llmdocs/generated RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server FROM alpine:3.22 diff --git a/Taskfile.yml b/Taskfile.yml index 9ae8188..c8a803a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,6 +3,7 @@ version: '3' vars: BINARY: renewcms DIST_DIR: internal/infrastructure/assets/dist + LLMS_DIR: internal/infrastructure/llmdocs/generated VERSION: sh: git describe --tags --always --dirty LDFLAGS: -s -w -X main.version={{.VERSION}} @@ -32,6 +33,7 @@ tasks: dev:api: desc: Run the API with hot reload on :8080 (SPA not embedded) + deps: [build:llms] cmds: - air @@ -236,17 +238,31 @@ tasks: cmds: - bun run build + build:llms: + desc: Generate llms.txt, llms-full.txt and openapi.json from the route catalogue and docs/ → {{.LLMS_DIR}} + deps: [web:install] + sources: [scripts/generate-llms.ts, web/src/data/apiCatalogue.ts, docs/api.md, docs/api/*.md, docs/llm/*.md, docs/configuration.md] + generates: ['{{.LLMS_DIR}}/llms-full.txt'] + cmds: + - bun scripts/generate-llms.ts + build: desc: Production binary for this machine → bin/{{.BINARY}} - deps: [build:web] + deps: [build:web, build:llms] cmds: - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server + build:mcp: + desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp + cmds: + - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp + release: desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY) cmds: - task: release:check - task: build:web + - task: build:llms - for: { var: RELEASE_TARGETS } task: build:target vars: diff --git a/api/handlers/llms/llmsHandler.go b/api/handlers/llms/llmsHandler.go new file mode 100644 index 0000000..9418f58 --- /dev/null +++ b/api/handlers/llms/llmsHandler.go @@ -0,0 +1,76 @@ +package llms + +import ( + "io/fs" + "net/http" + "path" + "strings" + + "github.com/go-chi/chi/v5" +) + +const ( + versionPlaceholder = "{{VERSION}}" + baseURLPlaceholder = "{{BASE_URL}}" + pageSuffix = ".md" + cacheControl = "public, max-age=300" + notBuiltNotice = "The LLM documentation was not generated for this build. Run `task build:llms`." +) + +type Handler struct { + docs fs.FS + version string + baseURL string +} + +func NewHandler(docs fs.FS, version string, publicURL string) *Handler { + return &Handler{ + docs: docs, + version: version, + baseURL: strings.TrimRight(publicURL, "/"), + } +} + +func (h *Handler) Register(r chi.Router) { + r.Get("/llms.txt", h.serve("llms.txt", "text/plain; charset=utf-8")) + r.Get("/llms-full.txt", h.serve("llms-full.txt", "text/plain; charset=utf-8")) + r.Get("/openapi.json", h.serve("openapi.json", "application/json")) + r.Get("/llms/{page}", h.page) +} + +func (h *Handler) page(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "page") + if !strings.HasSuffix(name, pageSuffix) || strings.ContainsAny(name, "/\\") { + h.notFound(w) + return + } + h.write(w, path.Join("llms", name), "text/markdown; charset=utf-8") +} + +func (h *Handler) serve(name string, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + h.write(w, name, contentType) + } +} + +func (h *Handler) write(w http.ResponseWriter, name string, contentType string) { + if h.docs == nil { + h.notFound(w) + return + } + raw, err := fs.ReadFile(h.docs, name) + if err != nil { + h.notFound(w) + return + } + text := strings.NewReplacer(versionPlaceholder, h.version, baseURLPlaceholder, h.baseURL).Replace(string(raw)) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", cacheControl) + _, _ = w.Write([]byte(text)) +} + +func (h *Handler) notFound(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(notBuiltNotice)) +} diff --git a/api/router.go b/api/router.go index c9d7b28..11f1a80 100644 --- a/api/router.go +++ b/api/router.go @@ -11,6 +11,7 @@ import ( "RenewCMS/api/handlers/entry" "RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/imagevariant" + "RenewCMS/api/handlers/llms" "RenewCMS/api/handlers/media" "RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/site" @@ -20,6 +21,7 @@ import ( "RenewCMS/api/middleware" "RenewCMS/api/pkg/authtoken" "RenewCMS/internal/infrastructure/config" + "RenewCMS/internal/infrastructure/llmdocs" "encoding/json" "log" "net/http" @@ -493,6 +495,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL)) apiRouter.Use(chimiddleware.GetHead) apiRouter.Get("/healthz", GetHealth) + llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter) apiRouter.Mount("/v1", backend) apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase)) apiRouter.Mount("/", frontend) diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go new file mode 100644 index 0000000..fc67806 --- /dev/null +++ b/cmd/mcp/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "RenewCMS/internal/infrastructure/mcpserver" + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var version = "dev" + +func main() { + allowDestructive := flag.Bool("allow-destructive", false, "also expose delete_article and delete_media") + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + + baseURL := os.Getenv("RENEWCMS_URL") + apiKey := os.Getenv("RENEWCMS_API_KEY") + if baseURL == "" || apiKey == "" { + log.Fatal("RENEWCMS_URL and RENEWCMS_API_KEY must be set") + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + server := mcpserver.New(mcpserver.NewClient(baseURL, apiKey, nil), mcpserver.Options{ + Version: version, + AllowDestructive: *allowDestructive, + }) + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} diff --git a/docs/llm/gotchas.md b/docs/llm/gotchas.md new file mode 100644 index 0000000..e0e7d0e --- /dev/null +++ b/docs/llm/gotchas.md @@ -0,0 +1,67 @@ +# Gotchas + +> What trips up API clients: session-only routes, error texts, rate limits and draft visibility. + +## Session-only routes + +An API key cannot call these routes. They answer `403` with a plain-text body and no JSON: + +``` +HTTP/1.1 403 Forbidden + +This action needs a real session. +``` + +- `/v1/api-key/*`, `/v1/system/*`, `POST /v1/auth/verify`, `DELETE /v1/auth/register`. +- Every activity route under `/v1/activity`. +- Writes on sites (`POST`, `PATCH`, `DELETE` under `/v1/site`, so a key cannot create a site) and on authors (`POST`, `PATCH`, `DELETE` under `/v1/author`). +- `PATCH /v1/settings`. + +Variant routes under `/v1/media/{id}/variants` answer `401` to a key instead. The sign-in routes (login, logout, register, password reset) ignore a key and give no `403`. + +When a task needs one of these routes, stop and tell the user to do it in the app. Do not try to log in with a password. + +## Error texts are plain text + +Errors are not JSON. The body is one sentence; branch on the status code, and show the sentence to the user. + +| Status | Typical text | +| --- | --- | +| `400` | `The request cannot be processed due to a mismatch in the format of the body.` | +| `401` | `You must be logged in to access this resource.` | +| `403` | `This action needs a real session.`, `Only the owner can do this.`, `Only owners and admins can do this.`, `You must verify your account to access this resource.` | +| `404` | `Article not found`, `Image not found` | +| `429` | `Too many requests. Please try again later.` | + +An invalid, unknown, expired or revoked key answers `401`, even on public routes. It is never treated as anonymous. + +## Rate limit: 429 + +Each key may make 120 requests per minute. The `429` response carries a `Retry-After` header holding the seconds to wait: + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 46 + +Too many requests. Please try again later. +``` + +Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop. Prefer one list call with `size=100` over many single reads. + +## Draft versus online + +- Anonymous callers and unverified users only see online articles. A draft answers `404` with `Article not found`, the same as an id that does not exist. +- A verified session or a key sees drafts as well. In lists, `counts.draft` is `0` for anonymous callers and `status=draft` answers an empty page. +- `POST /v1/article/` always creates a draft. Publishing is a separate call, `PATCH /v1/article/{id}/publish`. +- Sitemaps list online articles only. + +## Writes + +- A title cannot be changed after creation. Create a new article instead. +- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image. +- Request bodies are HTML even though responses default to markdown. Send `

` and `

`, not markdown syntax, or it is stored literally. +- Ids in paths are unsigned integers. Anything else answers `400`. + +## Permissions follow the key's creator + +A key acts as the user who made it. A key made by a user who is neither owner nor admin cannot do what only owners and admins can, and it answers `403 Only owners and admins can do this.` Check `GET /v1/auth/me` to read the role of the key. diff --git a/docs/llm/recipe-articles.md b/docs/llm/recipe-articles.md new file mode 100644 index 0000000..1b9ff3b --- /dev/null +++ b/docs/llm/recipe-articles.md @@ -0,0 +1,81 @@ +# Recipe: create, edit and publish an article + +> Create a draft, replace its body, check its SEO, publish it and read it back, with an API key. + +Set the key once. Every request below is real output from a running instance; ids and timestamps differ on yours. + +```sh +export RENEWCMS_API_KEY=rcms_... +``` + +## 1. Create a draft + +Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`. + +```sh +curl -X POST "{{BASE_URL}}/v1/article/" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"title":"How we price a bathroom renovation","body":"

A bathroom renovation is priced by room size, plumbing changes and finish level.

"}' +``` + +Status `201`: + +```json +{"id":1,"title":"How we price a bathroom renovation","body":"A bathroom renovation is priced by room size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":1,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.767761797+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"","seo_title":"","seo_description":"","cover_image_id":null,"cover_image_alt":"","social_title":"","social_description":"","social_image_id":null,"canonical_url":"","noindex":false,"seo_dismissed":[]} +``` + +The article starts offline (`is_online: false`) on the default site. + +## 2. Replace the body, move it to a site, set the excerpt + +`PATCH` takes only `body`, `site_id`, `author_ids` and the SEO fields. At least one is required; `{}` answers `400`. Send `{"body": ""}` to empty a body. + +```sh +curl -X PATCH "{{BASE_URL}}/v1/article/1" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"body":"

What drives the price

Room size, plumbing changes and finish level.

","site_id":2,"excerpt":"What drives the price of a bathroom renovation."}' +``` + +Status `200`: + +```json +{"id":1,"title":"How we price a bathroom renovation","body":"## What drives the price\n\nRoom size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.771008847+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"What drives the price of a bathroom renovation.","seo_title":"","seo_description":"","cover_image_id":null,"cover_image_alt":"","social_title":"","social_description":"","social_image_id":null,"canonical_url":"","noindex":false,"seo_dismissed":[]} +``` + +## 3. Check the SEO before publishing + +An empty JSON object checks the saved article. Pass any SEO field to check a change without saving it. + +```sh +curl -X POST "{{BASE_URL}}/v1/article/1/seo/check" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Status `200`: + +```json +{"status":"suggestions","suggestions":[{"code":"desc_short","field":"seo_description","params":{"length":47,"min":70}},{"code":"cover_none","field":"cover_image_id","params":{}},{"code":"no_au","field":"author_ids","params":{}},{"code":"short","field":"body","params":{"length":66,"min":300}}],"slug":{"available":true,"suggestion":null}} +``` + +Suggestions never block publishing. + +## 4. Publish + +```sh +curl -X PATCH "{{BASE_URL}}/v1/article/1/publish" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +Status `200`, same article object with `"is_online":true`. `PATCH /v1/article/{id}/unpublish` reverses it. + +## 5. Read it back without credentials + +```sh +curl "{{BASE_URL}}/v1/article/1?format=markdown" +``` + +Before step 4 this answers `404` with the text `Article not found`, because anonymous callers only see online articles. diff --git a/docs/llm/recipe-media.md b/docs/llm/recipe-media.md new file mode 100644 index 0000000..fff854c --- /dev/null +++ b/docs/llm/recipe-media.md @@ -0,0 +1,62 @@ +# Recipe: upload an image and attach it to an article + +> Upload a file with multipart form data, find it in the library and use it as an article cover. + +## 1. Upload + +Send the file in a form field named `file`. PNG, JPEG, WEBP and SVG are accepted. + +```sh +curl -X POST "{{BASE_URL}}/v1/media/" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -F "file=@north-wall.png" +``` + +Status `201`: + +```json +{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0} +``` + +`url` is relative to the instance: the public address is `{{BASE_URL}}` followed by `url`. `duplicate: true` means the same bytes were already in the library. + +## 2. Find an image + +```sh +curl "{{BASE_URL}}/v1/media/?size=5" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +Status `200`: + +```json +{"items":[{"id":1,"filename":"north-wall.png","url":"/static/uploadedImages/8d9be79c-5db6-4d1d-9f06-b5d1b15cb4f0.png","size":100,"content_type":"image/png","width":32,"height":32,"sha256":"7654144c6e1ee8939fa061fa8129763869fa3c2de55954053a53a965848ef09c","uploaded_by":"florian","created_at":"2026-10-23T01:11:24.228947139+02:00","used_in":0,"duplicate":false,"variant_count":0,"variant_size":0}],"total":1,"page":1,"size":5,"counts":{"all":1,"unused":1,"duplicates":0}} +``` + +Filter with `q`, `hash`, `filter` (`all`, `unused`, `duplicates`), `sort` and `variants`. + +## 3. Attach it as the cover + +An image is attached by id. `cover_image_alt` is the alternative text. + +```sh +curl -X PATCH "{{BASE_URL}}/v1/article/1" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"cover_image_id":1,"cover_image_alt":"A tiled bathroom wall"}' +``` + +Status `200`, the article object with `"cover_image_id":1` and `"cover_image_alt":"A tiled bathroom wall"`. Use `social_image_id` for the sharing image, and `null` to remove either image. + +## 4. See where an image is used + +```sh +curl "{{BASE_URL}}/v1/media/1/usage" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +```json +[{"type":"article","id":1,"title":"How we price a bathroom renovation","published":true,"fields":["cover"]}] +``` + +Deleting or merging an image that is in use changes those articles, so read this first. diff --git a/docs/llm/recipe-pagination.md b/docs/llm/recipe-pagination.md new file mode 100644 index 0000000..a3169b3 --- /dev/null +++ b/docs/llm/recipe-pagination.md @@ -0,0 +1,67 @@ +# Recipe: paginate, filter and choose a body format + +> List articles page by page, search and sort them, and read bodies as markdown, HTML or JSON blocks. + +`GET /v1/article/` always answers an envelope, never a bare array: + +```sh +curl "{{BASE_URL}}/v1/article/?format=html&size=5&page=1" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +```json +{"items":[{"id":1,"title":"How we price a bathroom renovation","body":"

What drives the price

Room size, plumbing changes and finish level.

","body_format":"html","is_online":true,"site_id":2,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:24.236138156+02:00","excerpt":"What drives the price of a bathroom renovation.","slug":"how-we-price-a-bathroom-renovation","seo_status":"suggestions","seo_suggestions":3}],"total":1,"page":1,"size":5,"counts":{"all":1,"online":1,"draft":0,"suggestions":1}} +``` + +In JSON, `<` and `>` are escaped as `<` and `>`; a JSON parser returns the plain characters. + +## Walking every page + +1. Request `page=1` with the `size` you want (`5`, `25`, `50` or `100`). +2. Read `total` and `size`. The number of pages is `ceil(total / size)`. +3. Request the next pages until `page * size >= total`. A page past the end answers `200` with `items: []`, so an empty `items` also ends the loop. + +Ordering is applied to the whole list before slicing and ends with the id, so pages never overlap or skip. + +## Filters + +Combine them freely. + +| Parameter | Example | Effect | +| --- | --- | --- | +| `q` | `q=grout` | Title contains the text, ignoring case and accents. Up to 200 characters. | +| `status` | `status=draft` | `online` or `draft`. | +| `site` | `site=renew-blog` | Only that site's articles. | +| `seo` | `seo=suggestions` | `ready`, `suggestions` or `hidden`. | +| `sort` | `sort=title:asc` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc`. Default `updated_at:desc`. | + +`counts` ignores every filter, so tab totals do not move while searching. `total` follows the filters. + +```sh +curl "{{BASE_URL}}/v1/article/?site=renew-blog&status=draft&sort=title:asc&q=grout" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +```json +{"items":[{"id":2,"title":"Draft about grout","body":"x","body_format":"markdown","is_online":false,"site_id":2,"authors":[],"created_at":"2026-10-23T01:12:24.922895943+02:00","updated_at":"2026-10-23T01:12:24.926728869+02:00","excerpt":"","slug":"draft-about-grout","seo_status":"suggestions","seo_suggestions":4}],"total":1,"page":1,"size":25,"counts":{"all":2,"online":1,"draft":1,"suggestions":2}} +``` + +## The `format` parameter + +| Value | `body` | `body_format` | +| --- | --- | --- | +| absent or `markdown` | markdown string | `markdown` | +| `html` | the stored HTML | `html` | +| `json` | array of blocks | `json` | + +It applies to every article route, including the writes. Request bodies of `POST` and `PATCH` stay HTML whatever the format. An invalid `format` is reported before any other error. + +## Invalid values + +A bad `page`, `size`, `status`, `sort` or `seo` answers `400` with a plain-text body: + +``` +The request cannot be processed due to a mismatch in the format of the body. +``` + +Media lists paginate the same way (`GET /v1/media/?page=2&size=50`) with their own `counts`. diff --git a/docs/llm/recipe-sites.md b/docs/llm/recipe-sites.md new file mode 100644 index 0000000..20fa5f1 --- /dev/null +++ b/docs/llm/recipe-sites.md @@ -0,0 +1,66 @@ +# Recipe: sites and sitemaps + +> Create a site (session only), move an article onto it and read its public sitemap. + +An article belongs to one site. A site has a `key` (a slug used in URLs) and a `base_url` (the public address its pages are served from). + +## 1. List the sites + +```sh +curl "{{BASE_URL}}/v1/site/" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" +``` + +```json +[{"id":1,"name":"Default","key":"default","base_url":"http://localhost:18080","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":true,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""}] +``` + +## 2. Create a site: not with a key + +Creating, editing, defaulting and deleting a site need a browser session from the owner. A key answers `403`: + +```sh +curl -X POST "{{BASE_URL}}/v1/site/" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"x","key":"x","base_url":"https://x.example"}' +``` + +``` +HTTP/1.1 403 Forbidden + +This action needs a real session. +``` + +Ask the owner to create the site in the app, or send the request with the owner's `jwt` cookie. With a session the same request answers `201`: + +```json +{"id":2,"name":"Renew blog","key":"renew-blog","base_url":"https://blog.renew.example","default_social_image_id":null,"default_robots":"index","locale":null,"is_default":false,"article_count":0,"site_name":"","title_template":"%title% | %site%","default_description":""} +``` + +## 3. Put an article on the site + +Use the site `id` as `site_id`: + +```sh +curl -X PATCH "{{BASE_URL}}/v1/article/1" \ + -H "Authorization: Bearer $RENEWCMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"site_id":2}' +``` + +## 4. Read the sitemap + +The sitemap is public and lists the online articles of one site, by its `key`: + +```sh +curl "{{BASE_URL}}/v1/site/renew-blog/sitemap" +``` + +```json +[{"url":"https://blog.renew.example/how-we-price-a-bathroom-renovation","modified_at":"2026-10-22T23:11:24Z","noindex":false}] +``` + +`url` is the site's `base_url` plus the article slug. Drafts are not listed. The response header `X-Total-Count` holds the number of entries. Page with `page` and `size` (`25`, `50`, `100` or `500`, default `100`); any other `size` answers `400`. An unknown key answers `404` with `site_not_found`. + +List only one site's articles with `GET /v1/article/?site=renew-blog`. diff --git a/docs/llms.md b/docs/llms.md new file mode 100644 index 0000000..1dab260 --- /dev/null +++ b/docs/llms.md @@ -0,0 +1,22 @@ +# LLM documentation + +A running instance serves its own documentation for language models, versioned with the binary: + +| URL | Content | +| --- | --- | +| `/llms.txt` | Short index with links, following llmstxt.org. | +| `/llms-full.txt` | Every page below in one file. | +| `/llms/.md` | One page: `api-reference`, `api`, `articles`, `api-keys`, `gotchas`, `recipe-articles`, `recipe-media`, `recipe-sites`, `recipe-pagination`, `configuration`. | +| `/openapi.json` | OpenAPI 3.1 description of the routes in the catalogue. | + +All of them are public. `{{VERSION}}` and `{{BASE_URL}}` in the sources are replaced by the binary's version and `PUBLIC_URL` when served. + +## How it is built + +`task build:llms` runs `scripts/generate-llms.ts` (bun) and writes into `internal/infrastructure/llmdocs/generated/`, which is gitignored and embedded in the binary. `task build`, `task release`, `task dev:api` and the Docker image run it first. Without it the routes answer 404 with a notice. + +- The API reference and `openapi.json` come from `web/src/data/apiCatalogue.ts`, the same file the Integration page renders. Edit the catalogue, not the output. +- `docs/api.md`, `docs/api/*.md`, the variables table of `docs/configuration.md` and every `docs/llm/*.md` are included as written. A page in `docs/llm/` starts with `# Title`, then a `> one-line summary` used by `llms.txt`. +- Recipes in `docs/llm/` hold real responses. Re-capture them when a response shape changes. + +`TestRouterRoutesAreInTheCatalogue` fails when a route of `api/router.go` is missing from the catalogue. Routes still undocumented are listed in `routesNotYetInTheCatalogue` in `test/api_catalogue_drift_test.go`; remove an entry once the route is documented. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..56fa46d --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,37 @@ +# MCP server + +`cmd/mcp` builds `renewcms-mcp`, a stdio [MCP](https://modelcontextprotocol.io) server for Claude Desktop, Claude Code, Cursor and similar clients. It is an HTTP client of the public API: it holds no database access and sends the API key as `Authorization: Bearer`, so what the key's user may do is the limit of what the model may do. + +Build it with `task build:mcp` (`bin/renewcms-mcp`). + +## Configuration + +| Name | Description | +| --- | --- | +| `RENEWCMS_URL` | Address of the instance, for example `https://cms.example`. | +| `RENEWCMS_API_KEY` | An API key created at `/integration/api-keys`. | + +They belong to the MCP binary only and are not server variables. + +```json +{ + "mcpServers": { + "renewcms": { + "command": "/path/to/renewcms-mcp", + "env": { "RENEWCMS_URL": "https://cms.example", "RENEWCMS_API_KEY": "rcms_..." } + } + } +} +``` + +## Tools + +`list_articles`, `get_article`, `create_article` (always a draft), `update_article_body`, `publish_article`, `check_seo`, `list_media`, `upload_media` (local `path`, or `base64` with `filename`), `list_sites`, `get_sitemap`. + +`--allow-destructive` adds `delete_article` and `delete_media`. + +Routes that need a browser session, such as creating a site, are not exposed. API errors come back as tool errors with the status and the plain-text message. + +## Resources and prompts + +The resource `renewcms://docs/llms-full.txt` is the instance's own [`/llms-full.txt`](llms.md), so the model reads the documentation of the exact version it talks to. The prompt `write_and_publish_article` drafts, checks the SEO and asks before publishing. diff --git a/go.mod b/go.mod index bee26e8..1812dc2 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-sql-driver/mysql v1.10.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/net v0.61.0 @@ -45,6 +46,7 @@ require ( github.com/glebarez/go-sqlite v1.23.0 // indirect github.com/go-playground/locales v0.14.2 // indirect github.com/go-playground/universal-translator v0.18.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.3 // indirect @@ -54,9 +56,14 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/stretchr/testify v1.12.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect + golang.org/x/time v0.15.0 // indirect modernc.org/libc v1.79.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index 0493073..69a49f5 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,12 @@ github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctS github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -85,16 +91,24 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= @@ -105,12 +119,16 @@ golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/infrastructure/llmdocs/generated/.gitkeep b/internal/infrastructure/llmdocs/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/infrastructure/llmdocs/llmdocs.go b/internal/infrastructure/llmdocs/llmdocs.go new file mode 100644 index 0000000..8e1288d --- /dev/null +++ b/internal/infrastructure/llmdocs/llmdocs.go @@ -0,0 +1,19 @@ +package llmdocs + +import ( + "embed" + "io/fs" +) + +//go:embed all:generated +var generatedEmbed embed.FS + +var Docs = mustSub(generatedEmbed, "generated") + +func mustSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic("llmdocs: " + err.Error()) + } + return sub +} diff --git a/internal/infrastructure/mcpserver/client.go b/internal/infrastructure/mcpserver/client.go new file mode 100644 index 0000000..94de62d --- /dev/null +++ b/internal/infrastructure/mcpserver/client.go @@ -0,0 +1,77 @@ +package mcpserver + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + requestTimeout = 60 * time.Second + maxResponseSize = 8 << 20 +) + +type Client struct { + baseURL string + apiKey string + http *http.Client +} + +type Response struct { + Status int + Body string +} + +func NewClient(baseURL string, apiKey string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: requestTimeout} + } + return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: httpClient} +} + +func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, body []byte, contentType string) (Response, error) { + target := c.baseURL + path + if len(query) > 0 { + target += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) + if err != nil { + return Response{}, err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + res, err := c.http.Do(req) + if err != nil { + return Response{}, err + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, maxResponseSize)) + if err != nil { + return Response{}, err + } + return Response{Status: res.StatusCode, Body: string(raw)}, nil +} + +func (r Response) Failed() bool { + return r.Status < 200 || r.Status > 299 +} + +func (r Response) Describe() string { + text := strings.TrimSpace(r.Body) + if r.Status == http.StatusNoContent || text == "" { + return fmt.Sprintf("HTTP %d", r.Status) + } + if r.Failed() { + return fmt.Sprintf("HTTP %d: %s", r.Status, text) + } + return text +} diff --git a/internal/infrastructure/mcpserver/server.go b/internal/infrastructure/mcpserver/server.go new file mode 100644 index 0000000..2abc34b --- /dev/null +++ b/internal/infrastructure/mcpserver/server.go @@ -0,0 +1,305 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + docsResourceURI = "renewcms://docs/llms-full.txt" + jsonType = "application/json" +) + +type Options struct { + Version string + AllowDestructive bool +} + +type server struct { + client *Client +} + +type listArticlesInput struct { + Page int `json:"page,omitempty" jsonschema:"page number from 1"` + Size int `json:"size,omitempty" jsonschema:"page size: 5, 25, 50 or 100"` + Query string `json:"q,omitempty" jsonschema:"text matched in the title"` + Status string `json:"status,omitempty" jsonschema:"online or draft"` + Sort string `json:"sort,omitempty" jsonschema:"field:direction, e.g. title:asc or updated_at:desc"` + Site string `json:"site,omitempty" jsonschema:"site key"` + SEO string `json:"seo,omitempty" jsonschema:"ready, suggestions or hidden"` + Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"` +} + +type getArticleInput struct { + ID int `json:"id" jsonschema:"article id"` + Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"` +} + +type createArticleInput struct { + Title string `json:"title" jsonschema:"3 to 200 characters, cannot be changed later"` + Body string `json:"body" jsonschema:"HTML body"` +} + +type updateArticleBodyInput struct { + ID int `json:"id" jsonschema:"article id"` + Body string `json:"body" jsonschema:"new HTML body, replaces the old one"` +} + +type articleIDInput struct { + ID int `json:"id" jsonschema:"article id"` +} + +type listMediaInput struct { + Query string `json:"q,omitempty" jsonschema:"search text"` + Filter string `json:"filter,omitempty" jsonschema:"all, unused or duplicates"` + Sort string `json:"sort,omitempty" jsonschema:"newest, oldest, name, largest or total_size"` + Page int `json:"page,omitempty" jsonschema:"page number from 1"` + Size int `json:"size,omitempty" jsonschema:"page size: 5, 25, 50 or 100"` +} + +type uploadMediaInput struct { + Path string `json:"path,omitempty" jsonschema:"path of a local PNG, JPEG, WEBP or SVG file"` + Base64 string `json:"base64,omitempty" jsonschema:"base64 file content, instead of path"` + Filename string `json:"filename,omitempty" jsonschema:"file name, required with base64"` +} + +type emptyInput struct{} + +type sitemapInput struct { + Key string `json:"key" jsonschema:"site key"` + Page int `json:"page,omitempty" jsonschema:"page number from 1"` + Size int `json:"size,omitempty" jsonschema:"25, 50, 100 or 500"` +} + +type mediaIDInput struct { + ID int `json:"id" jsonschema:"image id"` +} + +func New(client *Client, opts Options) *mcp.Server { + s := &server{client: client} + srv := mcp.NewServer(&mcp.Implementation{Name: "renewcms", Version: opts.Version}, &mcp.ServerOptions{ + Instructions: "Tools for a RenewCMS instance. Read the renewcms://docs/llms-full.txt resource for the API rules. Articles are created as drafts; ask before publishing.", + }) + + readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true} + additive := &mcp.ToolAnnotations{DestructiveHint: boolPtr(false)} + + mcp.AddTool(srv, &mcp.Tool{Name: "list_articles", Description: "List one page of articles with total and counts.", Annotations: readOnly}, s.listArticles) + mcp.AddTool(srv, &mcp.Tool{Name: "get_article", Description: "Get one article.", Annotations: readOnly}, s.getArticle) + mcp.AddTool(srv, &mcp.Tool{Name: "create_article", Description: "Create a draft article.", Annotations: additive}, s.createArticle) + mcp.AddTool(srv, &mcp.Tool{Name: "update_article_body", Description: "Replace the body of an article. The previous body is overwritten.", Annotations: &mcp.ToolAnnotations{IdempotentHint: true}}, s.updateArticleBody) + mcp.AddTool(srv, &mcp.Tool{Name: "publish_article", Description: "Make an article visible to everyone.", Annotations: &mcp.ToolAnnotations{IdempotentHint: true, DestructiveHint: boolPtr(false)}}, s.publishArticle) + mcp.AddTool(srv, &mcp.Tool{Name: "check_seo", Description: "Check the SEO of the saved article and list suggestions.", Annotations: readOnly}, s.checkSEO) + mcp.AddTool(srv, &mcp.Tool{Name: "list_media", Description: "List one page of the image library.", Annotations: readOnly}, s.listMedia) + mcp.AddTool(srv, &mcp.Tool{Name: "upload_media", Description: "Upload an image from a local path or base64 content.", Annotations: additive}, s.uploadMedia) + mcp.AddTool(srv, &mcp.Tool{Name: "list_sites", Description: "List the sites.", Annotations: readOnly}, s.listSites) + mcp.AddTool(srv, &mcp.Tool{Name: "get_sitemap", Description: "Read the public sitemap of a site.", Annotations: readOnly}, s.getSitemap) + + if opts.AllowDestructive { + mcp.AddTool(srv, &mcp.Tool{Name: "delete_article", Description: "Permanently delete an article."}, s.deleteArticle) + mcp.AddTool(srv, &mcp.Tool{Name: "delete_media", Description: "Permanently delete an image."}, s.deleteMedia) + } + + srv.AddResource(&mcp.Resource{ + URI: docsResourceURI, + Name: "llms-full.txt", + Description: "The full RenewCMS documentation served by the instance.", + MIMEType: "text/plain", + }, s.readDocs) + + srv.AddPrompt(&mcp.Prompt{ + Name: "write_and_publish_article", + Description: "Draft an article, check its SEO and publish it after confirmation.", + Arguments: []*mcp.PromptArgument{{Name: "topic", Description: "what the article is about", Required: true}}, + }, writePrompt) + + return srv +} + +func boolPtr(value bool) *bool { return &value } + +func intQuery(query url.Values, name string, value int) { + if value > 0 { + query.Set(name, strconv.Itoa(value)) + } +} + +func textQuery(query url.Values, name string, value string) { + if value != "" { + query.Set(name, value) + } +} + +func result(res Response, err error) (*mcp.CallToolResult, any, error) { + if err != nil { + return nil, nil, err + } + return &mcp.CallToolResult{ + IsError: res.Failed(), + Content: []mcp.Content{&mcp.TextContent{Text: res.Describe()}}, + }, nil, nil +} + +func (s *server) call(ctx context.Context, method string, path string, query url.Values, body any) (*mcp.CallToolResult, any, error) { + var raw []byte + contentType := "" + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + raw, contentType = encoded, jsonType + } + return result(s.client.Do(ctx, method, path, query, raw, contentType)) +} + +func articlePath(id int, suffix string) string { + return "/v1/article/" + strconv.Itoa(id) + suffix +} + +func (s *server) listArticles(ctx context.Context, _ *mcp.CallToolRequest, in listArticlesInput) (*mcp.CallToolResult, any, error) { + query := url.Values{} + intQuery(query, "page", in.Page) + intQuery(query, "size", in.Size) + textQuery(query, "q", in.Query) + textQuery(query, "status", in.Status) + textQuery(query, "sort", in.Sort) + textQuery(query, "site", in.Site) + textQuery(query, "seo", in.SEO) + textQuery(query, "format", in.Format) + return s.call(ctx, http.MethodGet, "/v1/article/", query, nil) +} + +func (s *server) getArticle(ctx context.Context, _ *mcp.CallToolRequest, in getArticleInput) (*mcp.CallToolResult, any, error) { + query := url.Values{} + textQuery(query, "format", in.Format) + return s.call(ctx, http.MethodGet, articlePath(in.ID, ""), query, nil) +} + +func (s *server) createArticle(ctx context.Context, _ *mcp.CallToolRequest, in createArticleInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodPost, "/v1/article/", nil, map[string]string{"title": in.Title, "body": in.Body}) +} + +func (s *server) updateArticleBody(ctx context.Context, _ *mcp.CallToolRequest, in updateArticleBodyInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodPatch, articlePath(in.ID, ""), nil, map[string]string{"body": in.Body}) +} + +func (s *server) publishArticle(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodPatch, articlePath(in.ID, "/publish"), nil, nil) +} + +func (s *server) checkSEO(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodPost, articlePath(in.ID, "/seo/check"), nil, map[string]string{}) +} + +func (s *server) deleteArticle(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodDelete, articlePath(in.ID, ""), nil, nil) +} + +func (s *server) listMedia(ctx context.Context, _ *mcp.CallToolRequest, in listMediaInput) (*mcp.CallToolResult, any, error) { + query := url.Values{} + textQuery(query, "q", in.Query) + textQuery(query, "filter", in.Filter) + textQuery(query, "sort", in.Sort) + intQuery(query, "page", in.Page) + intQuery(query, "size", in.Size) + return s.call(ctx, http.MethodGet, "/v1/media/", query, nil) +} + +func (s *server) deleteMedia(ctx context.Context, _ *mcp.CallToolRequest, in mediaIDInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodDelete, "/v1/media/"+strconv.Itoa(in.ID), nil, nil) +} + +func (s *server) uploadMedia(ctx context.Context, _ *mcp.CallToolRequest, in uploadMediaInput) (*mcp.CallToolResult, any, error) { + name, content, err := uploadContent(in) + if err != nil { + return nil, nil, err + } + var form bytes.Buffer + writer := multipart.NewWriter(&form) + part, err := writer.CreateFormFile("file", name) + if err != nil { + return nil, nil, err + } + if _, err := part.Write(content); err != nil { + return nil, nil, err + } + if err := writer.Close(); err != nil { + return nil, nil, err + } + return result(s.client.Do(ctx, http.MethodPost, "/v1/media/", nil, form.Bytes(), writer.FormDataContentType())) +} + +func uploadContent(in uploadMediaInput) (string, []byte, error) { + switch { + case in.Path != "" && in.Base64 != "": + return "", nil, fmt.Errorf("give either path or base64, not both") + case in.Path != "": + content, err := os.ReadFile(in.Path) + if err != nil { + return "", nil, err + } + name := in.Filename + if name == "" { + name = filepath.Base(in.Path) + } + return name, content, nil + case in.Base64 != "": + if in.Filename == "" { + return "", nil, fmt.Errorf("filename is required with base64") + } + content, err := base64.StdEncoding.DecodeString(in.Base64) + if err != nil { + return "", nil, fmt.Errorf("base64 content is not valid: %w", err) + } + return in.Filename, content, nil + } + return "", nil, fmt.Errorf("give a path or base64 content") +} + +func (s *server) listSites(ctx context.Context, _ *mcp.CallToolRequest, _ emptyInput) (*mcp.CallToolResult, any, error) { + return s.call(ctx, http.MethodGet, "/v1/site/", nil, nil) +} + +func (s *server) getSitemap(ctx context.Context, _ *mcp.CallToolRequest, in sitemapInput) (*mcp.CallToolResult, any, error) { + query := url.Values{} + intQuery(query, "page", in.Page) + intQuery(query, "size", in.Size) + return s.call(ctx, http.MethodGet, "/v1/site/"+url.PathEscape(in.Key)+"/sitemap", query, nil) +} + +func (s *server) readDocs(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + res, err := s.client.Do(ctx, http.MethodGet, "/llms-full.txt", nil, nil, "") + if err != nil { + return nil, err + } + if res.Failed() { + return nil, fmt.Errorf("the instance did not serve its documentation: %s", res.Describe()) + } + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: "text/plain", + Text: res.Body, + }}}, nil +} + +func writePrompt(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + text := "Write an article about " + req.Params.Arguments["topic"] + " for this RenewCMS instance. " + + "Create it as a draft with create_article, run check_seo and fix what is worth fixing with update_article_body, " + + "then show me the result and ask before calling publish_article." + return &mcp.GetPromptResult{ + Description: "Draft, check and publish an article", + Messages: []*mcp.PromptMessage{{Role: "user", Content: &mcp.TextContent{Text: text}}}, + }, nil +} diff --git a/scripts/generate-llms.ts b/scripts/generate-llms.ts new file mode 100644 index 0000000..c82c37e --- /dev/null +++ b/scripts/generate-llms.ts @@ -0,0 +1,321 @@ +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + API_GROUPS, + ERROR_ROWS, + PUBLIC_AUTH_ROWS, + SESSION_GROUPS, + endpointExamples, + type ApiEndpoint, + type ApiField, +} from '../web/src/data/apiCatalogue' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outDir = join(root, 'internal/infrastructure/llmdocs/generated') +const pagesDir = join(outDir, 'llms') + +const VERSION = '{{VERSION}}' +const BASE_URL = '{{BASE_URL}}' + +interface Page { + name: string + title: string + summary: string + body: () => string +} + +const read = (relative: string) => readFileSync(join(root, relative), 'utf8') + +function section(markdown: string, heading: string) { + const lines = markdown.split('\n') + const start = lines.findIndex((line) => line.trim() === heading) + if (start === -1) throw new Error(`heading not found: ${heading}`) + const level = heading.match(/^#+/)![0].length + const end = lines.findIndex( + (line, index) => index > start && /^#+ /.test(line) && line.match(/^#+/)![0].length <= level, + ) + return lines.slice(start, end === -1 ? undefined : end).join('\n') +} + +const docFiles = (directory: string) => + readdirSync(join(root, directory)) + .filter((file) => file.endsWith('.md')) + .sort() + .map((file) => `${directory}/${file}`) + +const fieldTable = (fields: ApiField[]) => + [ + '| Name | Type | Required | Rule |', + '| --- | --- | --- | --- |', + ...fields.map( + (field) => + `| \`${field.name}\` | ${field.type} | ${field.required ? 'yes' : 'no'} | ${field.rule.replaceAll('|', '\\|')} |`, + ), + ].join('\n') + +function endpointMarkdown(item: ApiEndpoint) { + const examples = endpointExamples(item, BASE_URL) + const parts = [ + `### ${item.method} ${item.path}`, + `Access: ${item.access}. Success status: ${item.status}.${item.rateLimited ? ' Rate limited.' : ''}`, + item.description, + ] + if (item.query.length > 0) parts.push(`Query parameters:\n\n${fieldTable(item.query)}`) + if (item.body.length > 0) { + const kind = item.bodyKind === 'multipart' ? 'multipart/form-data' : 'JSON' + parts.push(`Body (${kind}):\n\n${fieldTable(item.body)}`) + } + parts.push(`Example:\n\n\`\`\`sh\n${examples.curl}\n\`\`\``) + parts.push(`Response:\n\n\`\`\`\n${item.response}\n\`\`\``) + if (item.errors.length > 0) { + parts.push( + `Errors:\n\n${item.errors.map((error) => `- ${error.status}: ${error.caption}`).join('\n')}`, + ) + } + return parts.join('\n\n') +} + +function apiReference() { + const parts = [ + '# API reference', + `Generated from the route catalogue of RenewCMS ${VERSION}. Base URL: ${BASE_URL}. Every path starts with /v1. Send \`Authorization: Bearer rcms_...\` on every route except the public ones.`, + ] + for (const group of API_GROUPS) { + parts.push(`## ${group.title}`, group.description) + parts.push(...group.endpoints.map(endpointMarkdown)) + } + parts.push( + '## Session-only routes', + 'A key sent to these routes answers 403 "This action needs a real session.", except the sign-in routes, which ignore a key.', + ) + for (const group of SESSION_GROUPS) { + parts.push( + `### ${group.title}`, + group.rows.map((row) => `- ${row.method} ${row.path}: ${row.description}`).join('\n'), + ) + } + parts.push( + '## Public routes outside the article reads', + PUBLIC_AUTH_ROWS.map((row) => `- ${row.method} ${row.path}: ${row.description}`).join('\n'), + ) + parts.push( + '## Errors', + ERROR_ROWS.map( + (row) => + `- ${row.status} ${row.meaning}: ${row.messages.map((message) => `"${message}"`).join(', ')}${row.note ? ` ${row.note}` : ''}`, + ).join('\n'), + ) + return parts.join('\n\n') +} + +const API_PAGE_SUMMARIES: Record = { + articles: 'Article reads: pagination, search, counts, the format parameter, SEO status.', + 'api-keys': 'Creating, listing and revoking API keys, and where a key is accepted.', +} + +const pages: Page[] = [ + { + name: 'api-reference', + title: 'API reference', + summary: 'Every key-callable route with parameters, bodies, curl examples, responses and errors.', + body: apiReference, + }, + { + name: 'api', + title: 'API overview', + summary: 'Base URL, authentication, API key limits, activity log.', + body: () => read('docs/api.md'), + }, + ...docFiles('docs/api').map((file) => ({ + name: basename(file, '.md'), + title: read(file).match(/^# (.+)$/m)?.[1] ?? basename(file, '.md'), + summary: API_PAGE_SUMMARIES[basename(file, '.md')] ?? '', + body: () => read(file), + })), + ...docFiles('docs/llm').map((file) => ({ + name: basename(file, '.md'), + title: read(file).match(/^# (.+)$/m)?.[1] ?? basename(file, '.md'), + summary: read(file).match(/^> (.+)$/m)?.[1] ?? '', + body: () => read(file).replace(/^> .+\n+/m, ''), + })), + { + name: 'configuration', + title: 'Configuration', + summary: 'Environment variables of the server.', + body: () => `# Configuration\n\n${section(read('docs/configuration.md'), '## Variables')}`, + }, +] + +const pageNames = new Map(pages.map((page) => [page.name, page])) + +function rewriteLinks(markdown: string) { + return markdown.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, text: string, target: string) => { + if (/^(https?:|mailto:|#|\{\{)/.test(target)) return whole + const [file, anchor] = target.split('#') + const page = pageNames.get(basename(file, '.md')) + if (!page || !file.endsWith('.md')) return text + return `[${text}](${BASE_URL}/llms/${page.name}.md${anchor ? `#${anchor}` : ''})` + }) +} + +const rendered = pages.map((page) => ({ page, text: rewriteLinks(page.body()).trim() })) + +const index = [ + '# RenewCMS', + `> A small self-hosted CMS. A JSON API under /v1 manages articles, media and sites; articles and sitemaps are readable without credentials. Version ${VERSION}, served by ${BASE_URL}.`, + 'Authenticate with an API key: `Authorization: Bearer rcms_...`. A key acts as its creator, 120 requests per minute. Some routes need a browser session and answer 403 to a key; see the gotchas page.', + '## Docs', + rendered + .map(({ page }) => `- [${page.title}](${BASE_URL}/llms/${page.name}.md): ${page.summary}`) + .join('\n'), + '## Machine-readable', + [ + `- [llms-full.txt](${BASE_URL}/llms-full.txt): all of the pages above in one file`, + `- [openapi.json](${BASE_URL}/openapi.json): OpenAPI 3.1 description of the routes in the API reference`, + ].join('\n'), +].join('\n\n') + +const full = [ + `# RenewCMS documentation\n\nVersion ${VERSION}. Served by ${BASE_URL}.`, + ...rendered.map(({ text }) => text), +].join('\n\n---\n\n') + +const SCALARS: Record = { + string: { type: 'string' }, + number: { type: 'number' }, + boolean: { type: 'boolean' }, + null: { type: 'null' }, + file: { type: 'string', format: 'binary' }, +} + +function schemaOf(type: string): object { + const alternatives = type.split('|').map((part) => part.trim()) + if (alternatives.length > 1) return { anyOf: alternatives.map(schemaOf) } + if (type.endsWith('[]')) return { type: 'array', items: schemaOf(type.slice(0, -2)) } + return SCALARS[type] ?? {} +} + +const objectSchema = (fields: ApiField[]) => ({ + type: 'object', + properties: Object.fromEntries( + fields.map((field) => [field.name, { ...schemaOf(field.type), description: field.rule }]), + ), + required: fields.filter((field) => field.required).map((field) => field.name), +}) + +function parseExample(response: string) { + try { + return JSON.parse(response) + } catch { + return undefined + } +} + +function operation(item: ApiEndpoint) { + const pathParameters = [...item.path.matchAll(/\{(\w+)\}/g)].map((match) => ({ + name: match[1], + in: 'path', + required: true, + schema: match[1] === 'id' ? { type: 'integer', minimum: 0 } : { type: 'string' }, + })) + const queryParameters = item.query.map((field) => ({ + name: field.name, + in: 'query', + required: field.required, + description: field.rule, + schema: schemaOf(field.type), + })) + const example = parseExample(item.response) + const responses: Record = { + [String(item.status)]: + item.status === 204 + ? { description: 'No content' } + : { + description: 'Success', + content: { + 'application/json': { schema: {}, ...(example === undefined ? {} : { example }) }, + }, + }, + } + for (const error of item.errors) { + const key = String(error.status) + const previous = (responses[key] as { description?: string } | undefined)?.description + responses[key] = { + description: previous ? `${previous} / ${error.caption}` : error.caption, + content: { 'text/plain': { schema: { type: 'string' } } }, + } + } + const result: Record = { + operationId: item.id, + summary: item.description, + 'x-access': item.access, + parameters: [...pathParameters, ...queryParameters], + responses, + } + if (item.access !== 'Public') result.security = [{ bearerAuth: [] }, { cookieAuth: [] }] + if (item.bodyKind === 'json') { + const media = { + schema: objectSchema(item.body), + ...(item.bodySample ? { example: item.bodySample } : {}), + } + result.requestBody = { required: true, content: { 'application/json': media } } + } + if (item.bodyKind === 'multipart') { + result.requestBody = { + required: true, + content: { 'multipart/form-data': { schema: objectSchema(item.body) } }, + } + } + return result +} + +const paths: Record> = {} +for (const group of API_GROUPS) { + for (const item of group.endpoints) { + paths[item.path] ??= {} + paths[item.path][item.method.toLowerCase()] = operation(item) + } +} +for (const group of SESSION_GROUPS) { + for (const row of group.rows) { + paths[row.path] ??= {} + paths[row.path][row.method.toLowerCase()] = { + summary: row.description, + 'x-session-only': row.answers403, + security: [{ cookieAuth: [] }], + responses: { '200': { description: 'Success' } }, + } + } +} +for (const row of PUBLIC_AUTH_ROWS) { + paths[row.path] ??= {} + paths[row.path][row.method.toLowerCase()] = { + summary: row.description, + responses: { '200': { description: 'Success' } }, + } +} + +const openapi = { + openapi: '3.1.0', + info: { + title: 'RenewCMS API', + version: VERSION, + description: 'Generated from the route catalogue. Routes listed with x-session-only answer 403 to an API key.', + }, + servers: [{ url: BASE_URL }], + paths, + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', description: 'An API key starting with rcms_' }, + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'jwt' }, + }, + }, +} + +rmSync(pagesDir, { recursive: true, force: true }) +mkdirSync(pagesDir, { recursive: true }) +for (const { page, text } of rendered) writeFileSync(join(pagesDir, `${page.name}.md`), `${text}\n`) +writeFileSync(join(outDir, 'llms.txt'), `${index}\n`) +writeFileSync(join(outDir, 'llms-full.txt'), `${full}\n`) +writeFileSync(join(outDir, 'openapi.json'), `${JSON.stringify(openapi, null, 2)}\n`) diff --git a/test/api_catalogue_drift_test.go b/test/api_catalogue_drift_test.go new file mode 100644 index 0000000..eeec12b --- /dev/null +++ b/test/api_catalogue_drift_test.go @@ -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")) + } +} diff --git a/test/llms_handler_http_test.go b/test/llms_handler_http_test.go new file mode 100644 index 0000000..479e130 --- /dev/null +++ b/test/llms_handler_http_test.go @@ -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) + } +} diff --git a/test/mcp_server_test.go b/test/mcp_server_test.go new file mode 100644 index 0000000..0b25e36 --- /dev/null +++ b/test/mcp_server_test.go @@ -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": "

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) +} diff --git a/web/src/data/apiCatalogue.ts b/web/src/data/apiCatalogue.ts index d7e22c0..ab56ec6 100644 --- a/web/src/data/apiCatalogue.ts +++ b/web/src/data/apiCatalogue.ts @@ -28,6 +28,7 @@ export interface ApiEndpoint { bodySample: Record | null errors: ApiError[] response: string + status: number rateLimited: boolean } @@ -542,6 +543,7 @@ function endpoint( bodySample: null, errors, response, + status: response === NO_CONTENT_RESPONSE ? 204 : 200, rateLimited: false, ...extra, } @@ -638,6 +640,7 @@ export const API_GROUPS: ApiGroup[] = [ title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.', }, + status: 201, }, ), endpoint( @@ -886,6 +889,7 @@ export const API_GROUPS: ApiGroup[] = [ json(mediaItem()), { pathId: null, + status: 201, bodyKind: 'multipart', body: [ { @@ -1156,6 +1160,7 @@ export const API_GROUPS: ApiGroup[] = [ ], json(invite()), { + status: 201, bodyKind: 'json', body: [ { diff --git a/web/vite.config.ts b/web/vite.config.ts index 5e8ded6..a62ed1a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -21,6 +21,10 @@ export default defineConfig({ '/v1': 'http://localhost:8080', '/healthz': 'http://localhost:8080', '/static': 'http://localhost:8080', + '/llms.txt': 'http://localhost:8080', + '/llms-full.txt': 'http://localhost:8080', + '/llms/': 'http://localhost:8080', + '/openapi.json': 'http://localhost:8080', }, }, plugins: [