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: [