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

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

No files matched your search

+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.
+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: [
{
+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: [