Compare commits

...
30 changed files with 1744 additions and 3 deletions

No files matched your search

+4
View File
@@ -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
+5
View File
@@ -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
+17 -1
View File
@@ -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:
+76
View File
@@ -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))
}
+3
View File
@@ -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)
+43
View File
@@ -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)
}
}
+67
View File
@@ -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 `<p>` and `<h2>`, 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.
+81
View File
@@ -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":"<p>A bathroom renovation is priced by room size, plumbing changes and finish level.</p>"}'
```
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":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","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.
+62
View File
@@ -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.
+67
View File
@@ -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":"<h2>What drives the price</h2><p>Room size, plumbing changes and finish level.</p>","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`.
+66
View File
@@ -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`.
+22
View File
@@ -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/<page>.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.
+37
View File
@@ -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.
+2
View File
@@ -44,3 +44,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v3.4.1-beta` | Media field and security updates |
| `v3.4.2-beta` | Security and privacy hardening |
| `v3.5.0-beta` | Relation field |
| `v3.5.1-beta` | Relation picker fix |
| `v3.5.2-beta` | LLM documentation and MCP server |
+9
View File
@@ -0,0 +1,9 @@
This release fixes the Relation field picker, which could not load any entries.
## Improvements and fixes
- Choosing a target for a Relation field no longer fails with "Couldn't load" and a `400` error. The picker asked for pages of 10 entries, a size the entry list refuses, and now asks for 25.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
+28
View File
@@ -0,0 +1,28 @@
This release lets language models read and use a RenewCMS instance: the server publishes its own documentation, an OpenAPI description, and a separate MCP server drives the API with a key.
## New
**Documentation for language models**
- Every instance serves `/llms.txt` (a short index), `/llms-full.txt` (all the documentation in one file), `/llms/<page>.md` (one page each) and `/openapi.json`. They are public, and carry the version and address of the instance that serves them.
- The pages include the API reference, the API key and article guides, ready-to-run recipes with real responses (publish an article, upload and attach an image, sites and sitemaps, pagination and formats), and a gotchas page on session-only routes, error texts, rate limits and draft visibility.
**MCP server**
- The new `renewcms-mcp` binary is a stdio MCP server for Claude Desktop, Claude Code, Cursor and similar clients. It calls the public API with an API key, so the key's rights limit what the model can do.
- Tools: list, get, create, update the body of and publish articles, check SEO, list and upload media, list sites and read a sitemap. Deletion tools are only offered with `--allow-destructive`.
- It exposes the instance's `llms-full.txt` as a resource and a prompt that drafts, checks and asks before publishing. Setup is in `docs/mcp.md`.
## API
- `GET /llms.txt`, `GET /llms-full.txt`, `GET /llms/{page}` and `GET /openapi.json` are public routes outside `/v1`. They answer `404` with a notice when the build did not generate them.
## Under the hood
- The API reference and `openapi.json` are generated from `web/src/data/apiCatalogue.ts` by `task build:llms`, which `task build`, `task release` and the Docker image run first.
- A new test fails when a route of the router is missing from the catalogue. Twenty-five existing routes (content types, entries, builtin types, image variants, media variants and the user lookup) are still undocumented and listed as exceptions in that test.
- The catalogue now records the success status of each endpoint.
- `task build:mcp` builds the MCP server. `task release` does not include it yet.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
- The MCP server reads `RENEWCMS_URL` and `RENEWCMS_API_KEY` in its own process; the server does not need them.
+7
View File
@@ -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
+18
View File
@@ -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=
Whitespace-only changes.
@@ -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
}
@@ -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
}
+305
View File
@@ -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
}
+321
View File
@@ -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<string, string> = {
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, object> = {
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, object> = {
[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<string, unknown> = {
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<string, Record<string, unknown>> = {}
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`)
+120
View File
@@ -0,0 +1,120 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/infrastructure/config"
"net/http"
"os"
"regexp"
"sort"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
var catalogueRoutePatterns = []*regexp.Regexp{
regexp.MustCompile(`\w+\(\s*'[^']+',\s*'([A-Z]+)',\s*'([^']+)'`),
regexp.MustCompile(`row\(\s*'([A-Z]+)',\s*'([^']+)'`),
regexp.MustCompile(`method: '([A-Z]+)',\s*path: '([^']+)'`),
}
func catalogueRoutes(t *testing.T) map[string]bool {
t.Helper()
raw, err := os.ReadFile("../web/src/data/apiCatalogue.ts")
if err != nil {
t.Fatal(err)
}
routes := map[string]bool{}
for _, pattern := range catalogueRoutePatterns {
for _, m := range pattern.FindAllStringSubmatch(string(raw), -1) {
routes[m[1]+" "+strings.TrimSuffix(m[2], "/")] = true
}
}
return routes
}
func routerRoutes(t *testing.T) map[string]bool {
t.Helper()
router := api.InitBackendRoutes(&api.UseCases{}, config.Settings{})
routes := map[string]bool{}
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
route = strings.ReplaceAll(route, "/*", "")
routes[method+" /v1"+strings.TrimSuffix(route, "/")] = true
return nil
})
if err != nil {
t.Fatal(err)
}
return routes
}
var routesNotYetInTheCatalogue = []string{
"DELETE /v1/content-type/{key}",
"DELETE /v1/content-type/{key}/entry/{id}",
"DELETE /v1/image-variants/{name}",
"DELETE /v1/media/{id}/variants/{name}",
"GET /v1/builtin-type",
"GET /v1/builtin-type/{key}",
"GET /v1/content-type",
"GET /v1/content-type/{key}",
"GET /v1/content-type/{key}/entry",
"GET /v1/content-type/{key}/entry/{id}",
"GET /v1/image-variants",
"GET /v1/image-variants/cache",
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
"POST /v1/image-variants",
"POST /v1/image-variants/cache/purge",
"POST /v1/image-variants/{name}/purge",
"POST /v1/media/{id}/variants",
"POST /v1/media/{id}/variants/{name}",
"PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}",
}
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
documented := catalogueRoutes(t)
exempt := map[string]bool{}
for _, route := range routesNotYetInTheCatalogue {
exempt[route] = true
}
actual := routerRoutes(t)
var missing, stale []string
for route := range actual {
if !documented[route] && !exempt[route] {
missing = append(missing, route)
}
}
for route := range exempt {
if documented[route] || !actual[route] {
stale = append(stale, route)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 {
t.Errorf("routes missing from web/src/data/apiCatalogue.ts:\n%s", strings.Join(missing, "\n"))
}
if len(stale) > 0 {
t.Errorf("routes to remove from routesNotYetInTheCatalogue:\n%s", strings.Join(stale, "\n"))
}
}
func TestCatalogueRoutesExistInTheRouter(t *testing.T) {
actual := routerRoutes(t)
var unknown []string
for route := range catalogueRoutes(t) {
if strings.Contains(route, " /v1") && !actual[route] {
unknown = append(unknown, route)
}
}
sort.Strings(unknown)
if len(unknown) > 0 {
t.Errorf("catalogue routes the router does not serve:\n%s", strings.Join(unknown, "\n"))
}
}
+76
View File
@@ -0,0 +1,76 @@
package test
import (
"RenewCMS/api/handlers/llms"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
)
func llmsRouter(docs fstest.MapFS) http.Handler {
r := chi.NewRouter()
llms.NewHandler(docs, "v9.9.9", "https://cms.example/").Register(r)
return r
}
func llmsGet(handler http.Handler, target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
func TestLlmsServesGeneratedDocsWithVersionAndBaseURL(t *testing.T) {
handler := llmsRouter(fstest.MapFS{
"llms.txt": {Data: []byte("# RenewCMS {{VERSION}}\n{{BASE_URL}}/llms-full.txt")},
"llms-full.txt": {Data: []byte("full {{VERSION}}")},
"openapi.json": {Data: []byte(`{"version":"{{VERSION}}","servers":[{"url":"{{BASE_URL}}"}]}`)},
"llms/gotchas.md": {Data: []byte("# Gotchas {{VERSION}}")},
"llms/private.txt": {Data: []byte("not a page")},
})
cases := []struct {
target string
contentType string
body string
}{
{"/llms.txt", "text/plain; charset=utf-8", "# RenewCMS v9.9.9\nhttps://cms.example/llms-full.txt"},
{"/llms-full.txt", "text/plain; charset=utf-8", "full v9.9.9"},
{"/openapi.json", "application/json", `{"version":"v9.9.9","servers":[{"url":"https://cms.example"}]}`},
{"/llms/gotchas.md", "text/markdown; charset=utf-8", "# Gotchas v9.9.9"},
}
for _, tc := range cases {
rec := llmsGet(handler, tc.target)
if rec.Code != http.StatusOK {
t.Fatalf("%s answered %d", tc.target, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != tc.contentType {
t.Errorf("%s content type %q, want %q", tc.target, got, tc.contentType)
}
if rec.Body.String() != tc.body {
t.Errorf("%s body %q, want %q", tc.target, rec.Body.String(), tc.body)
}
}
}
func TestLlmsAnswers404WhenNothingWasGenerated(t *testing.T) {
handler := llmsRouter(fstest.MapFS{})
for _, target := range []string{"/llms.txt", "/llms-full.txt", "/openapi.json", "/llms/gotchas.md", "/llms/private.txt", "/llms/..%2Fllms.txt"} {
rec := llmsGet(handler, target)
if rec.Code != http.StatusNotFound {
t.Errorf("%s answered %d, want 404", target, rec.Code)
}
if !strings.Contains(rec.Body.String(), "task build:llms") {
t.Errorf("%s body %q does not name the task", target, rec.Body.String())
}
}
}
func TestLlmsOnlyServesMarkdownPages(t *testing.T) {
handler := llmsRouter(fstest.MapFS{"llms/private.txt": {Data: []byte("not a page")}})
if rec := llmsGet(handler, "/llms/private.txt"); rec.Code != http.StatusNotFound {
t.Fatalf("answered %d, want 404", rec.Code)
}
}
+189
View File
@@ -0,0 +1,189 @@
package test
import (
"RenewCMS/internal/infrastructure/mcpserver"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type recordedCall struct {
method string
target string
auth string
body string
ctype string
}
type mcpFixture struct {
session *mcp.ClientSession
mu sync.Mutex
calls []recordedCall
}
func (f *mcpFixture) last() recordedCall {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[len(f.calls)-1]
}
func newMCPFixture(t *testing.T, allowDestructive bool, respond func(w http.ResponseWriter, r *http.Request)) *mcpFixture {
t.Helper()
f := &mcpFixture{}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.calls = append(f.calls, recordedCall{r.Method, r.URL.RequestURI(), r.Header.Get("Authorization"), string(raw), r.Header.Get("Content-Type")})
f.mu.Unlock()
respond(w, r)
}))
t.Cleanup(api.Close)
server := mcpserver.New(mcpserver.NewClient(api.URL+"/", "rcms_key", nil), mcpserver.Options{Version: "test", AllowDestructive: allowDestructive})
ctx := context.Background()
serverSide, clientSide := mcp.NewInMemoryTransports()
if _, err := server.Connect(ctx, serverSide, nil); err != nil {
t.Fatal(err)
}
session, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, clientSide, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = session.Close() })
f.session = session
return f
}
func (f *mcpFixture) callTool(t *testing.T, name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := f.session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
func toolText(res *mcp.CallToolResult) string {
return res.Content[0].(*mcp.TextContent).Text
}
func echoStatus(status int, body string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}
}
func TestMCPToolsMapToTheAPIWithTheKey(t *testing.T) {
f := newMCPFixture(t, false, echoStatus(200, `{"ok":true}`))
cases := []struct {
tool string
args map[string]any
method string
target string
body string
}{
{"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""},
{"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""},
{"create_article", map[string]any{"title": "Grout tips", "body": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
{"publish_article", map[string]any{"id": 7}, "PATCH", "/v1/article/7/publish", ""},
{"check_seo", map[string]any{"id": 7}, "POST", "/v1/article/7/seo/check", `{}`},
{"list_media", map[string]any{"filter": "unused"}, "GET", "/v1/media/?filter=unused", ""},
{"list_sites", map[string]any{}, "GET", "/v1/site/", ""},
{"get_sitemap", map[string]any{"key": "renew-blog", "size": 25}, "GET", "/v1/site/renew-blog/sitemap?size=25", ""},
}
for _, tc := range cases {
res := f.callTool(t, tc.tool, tc.args)
if res.IsError || toolText(res) != `{"ok":true}` {
t.Fatalf("%s: error=%v text=%q", tc.tool, res.IsError, toolText(res))
}
call := f.last()
if call.method != tc.method || call.target != tc.target || !sameJSONBody(call.body, tc.body) || call.auth != "Bearer rcms_key" {
t.Errorf("%s sent %q want %q", tc.tool, call.body, tc.body)
}
}
}
func TestMCPReportsAPIErrorsAsToolErrors(t *testing.T) {
f := newMCPFixture(t, false, echoStatus(403, "This action needs a real session."))
res := f.callTool(t, "list_sites", map[string]any{})
if !res.IsError || toolText(res) != "HTTP 403: This action needs a real session." {
t.Fatalf("error=%v text=%q", res.IsError, toolText(res))
}
}
func TestMCPUploadsMultipartFromBase64(t *testing.T) {
f := newMCPFixture(t, false, echoStatus(201, `{"id":1}`))
res := f.callTool(t, "upload_media", map[string]any{"base64": base64.StdEncoding.EncodeToString([]byte("PNGDATA")), "filename": "wall.png"})
if res.IsError {
t.Fatal(toolText(res))
}
call := f.last()
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") {
t.Errorf("sent %+v", call)
}
if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError {
t.Error("an upload without content must fail")
}
}
func TestMCPDestructiveToolsNeedTheFlag(t *testing.T) {
names := func(f *mcpFixture) map[string]bool {
found := map[string]bool{}
for tool, err := range f.session.Tools(context.Background(), nil) {
if err != nil {
t.Fatal(err)
}
found[tool.Name] = true
}
return found
}
safe := names(newMCPFixture(t, false, echoStatus(200, "")))
if safe["delete_article"] || safe["delete_media"] || !safe["publish_article"] || len(safe) != 10 {
t.Errorf("default tools: %v", safe)
}
f := newMCPFixture(t, true, echoStatus(204, ""))
if all := names(f); !all["delete_article"] || !all["delete_media"] {
t.Errorf("flagged tools: %v", all)
}
if res := f.callTool(t, "delete_article", map[string]any{"id": 3}); res.IsError || toolText(res) != "HTTP 204" || f.last().method != "DELETE" {
t.Errorf("delete: %q %+v", toolText(res), f.last())
}
}
func TestMCPServesTheInstanceDocumentationAsAResource(t *testing.T) {
f := newMCPFixture(t, false, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/llms-full.txt" {
w.WriteHeader(404)
return
}
_, _ = io.WriteString(w, "# RenewCMS documentation")
})
res, err := f.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "renewcms://docs/llms-full.txt"})
if err != nil {
t.Fatal(err)
}
if res.Contents[0].Text != "# RenewCMS documentation" {
t.Errorf("got %q", res.Contents[0].Text)
}
}
func sameJSONBody(got string, want string) bool {
if got == want {
return true
}
var a, b any
if json.Unmarshal([]byte(got), &a) != nil || json.Unmarshal([]byte(want), &b) != nil {
return false
}
return reflect.DeepEqual(a, b)
}
+5
View File
@@ -28,6 +28,7 @@ export interface ApiEndpoint {
bodySample: Record<string, unknown> | 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: [
{
+13 -1
View File
@@ -14,7 +14,13 @@ import { ruleMessage } from '@/lib/entryMessages'
import { groupFields } from '@/lib/entryLayout'
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
import { isRelationId, relationIdsOf, summariesOf, type RelationSummary } from '@/lib/relationField'
import {
RELATION_PAGE_SIZE,
isRelationId,
relationIdsOf,
summariesOf,
type RelationSummary,
} from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { ApiError } from '@/lib/apiError'
import { diffFields } from '@/lib/schemaBuilderDiff'
@@ -481,3 +487,9 @@ describe('relation in the builder model', () => {
expect(data.speakers).toEqual([])
})
})
describe('the picker page size', () => {
it('is one of the sizes the entry list endpoint accepts', () => {
expect([5, 25, 50, 100]).toContain(RELATION_PAGE_SIZE)
})
})
+1 -1
View File
@@ -7,7 +7,7 @@ export type RelationKind = (typeof RELATION_KINDS)[number]
export const DEFAULT_RELATION_KIND: RelationKind = 'one_to_many'
export const RELATION_MAX = 50
export const RELATION_PAGE_SIZE = 10
export const RELATION_PAGE_SIZE = 25
const RELATION_ID_MAX = 4294967295
+4
View File
@@ -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: [