feat: llms.txt and llms-full.txt, openapi.json, MCP server
This commit is contained in:
1 parent
daba3c04c0
commit
4cff43fefb
25 files changed
+1691
-1
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -0,0 +1,120 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var catalogueRoutePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\w+\(\s*'[^']+',\s*'([A-Z]+)',\s*'([^']+)'`),
|
||||
regexp.MustCompile(`row\(\s*'([A-Z]+)',\s*'([^']+)'`),
|
||||
regexp.MustCompile(`method: '([A-Z]+)',\s*path: '([^']+)'`),
|
||||
}
|
||||
|
||||
func catalogueRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../web/src/data/apiCatalogue.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes := map[string]bool{}
|
||||
for _, pattern := range catalogueRoutePatterns {
|
||||
for _, m := range pattern.FindAllStringSubmatch(string(raw), -1) {
|
||||
routes[m[1]+" "+strings.TrimSuffix(m[2], "/")] = true
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func routerRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
router := api.InitBackendRoutes(&api.UseCases{}, config.Settings{})
|
||||
routes := map[string]bool{}
|
||||
err := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
route = strings.ReplaceAll(route, "/*", "")
|
||||
routes[method+" /v1"+strings.TrimSuffix(route, "/")] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
var routesNotYetInTheCatalogue = []string{
|
||||
"DELETE /v1/content-type/{key}",
|
||||
"DELETE /v1/content-type/{key}/entry/{id}",
|
||||
"DELETE /v1/image-variants/{name}",
|
||||
"DELETE /v1/media/{id}/variants/{name}",
|
||||
"GET /v1/builtin-type",
|
||||
"GET /v1/builtin-type/{key}",
|
||||
"GET /v1/content-type",
|
||||
"GET /v1/content-type/{key}",
|
||||
"GET /v1/content-type/{key}/entry",
|
||||
"GET /v1/content-type/{key}/entry/{id}",
|
||||
"GET /v1/image-variants",
|
||||
"GET /v1/image-variants/cache",
|
||||
"GET /v1/media/{id}/variants",
|
||||
"GET /v1/user/lookup",
|
||||
"PATCH /v1/image-variants/{name}",
|
||||
"POST /v1/content-type",
|
||||
"POST /v1/content-type/{key}/entry",
|
||||
"POST /v1/image-variants",
|
||||
"POST /v1/image-variants/cache/purge",
|
||||
"POST /v1/image-variants/{name}/purge",
|
||||
"POST /v1/media/{id}/variants",
|
||||
"POST /v1/media/{id}/variants/{name}",
|
||||
"PUT /v1/content-type/{key}",
|
||||
"PUT /v1/content-type/{key}/entry/{id}",
|
||||
"PUT /v1/image-variants/{name}",
|
||||
}
|
||||
|
||||
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
|
||||
documented := catalogueRoutes(t)
|
||||
exempt := map[string]bool{}
|
||||
for _, route := range routesNotYetInTheCatalogue {
|
||||
exempt[route] = true
|
||||
}
|
||||
actual := routerRoutes(t)
|
||||
var missing, stale []string
|
||||
for route := range actual {
|
||||
if !documented[route] && !exempt[route] {
|
||||
missing = append(missing, route)
|
||||
}
|
||||
}
|
||||
for route := range exempt {
|
||||
if documented[route] || !actual[route] {
|
||||
stale = append(stale, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("routes missing from web/src/data/apiCatalogue.ts:\n%s", strings.Join(missing, "\n"))
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
t.Errorf("routes to remove from routesNotYetInTheCatalogue:\n%s", strings.Join(stale, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueRoutesExistInTheRouter(t *testing.T) {
|
||||
actual := routerRoutes(t)
|
||||
var unknown []string
|
||||
for route := range catalogueRoutes(t) {
|
||||
if strings.Contains(route, " /v1") && !actual[route] {
|
||||
unknown = append(unknown, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
if len(unknown) > 0 {
|
||||
t.Errorf("catalogue routes the router does not serve:\n%s", strings.Join(unknown, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/llms"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func llmsRouter(docs fstest.MapFS) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
llms.NewHandler(docs, "v9.9.9", "https://cms.example/").Register(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func llmsGet(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestLlmsServesGeneratedDocsWithVersionAndBaseURL(t *testing.T) {
|
||||
handler := llmsRouter(fstest.MapFS{
|
||||
"llms.txt": {Data: []byte("# RenewCMS {{VERSION}}\n{{BASE_URL}}/llms-full.txt")},
|
||||
"llms-full.txt": {Data: []byte("full {{VERSION}}")},
|
||||
"openapi.json": {Data: []byte(`{"version":"{{VERSION}}","servers":[{"url":"{{BASE_URL}}"}]}`)},
|
||||
"llms/gotchas.md": {Data: []byte("# Gotchas {{VERSION}}")},
|
||||
"llms/private.txt": {Data: []byte("not a page")},
|
||||
})
|
||||
cases := []struct {
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
}{
|
||||
{"/llms.txt", "text/plain; charset=utf-8", "# RenewCMS v9.9.9\nhttps://cms.example/llms-full.txt"},
|
||||
{"/llms-full.txt", "text/plain; charset=utf-8", "full v9.9.9"},
|
||||
{"/openapi.json", "application/json", `{"version":"v9.9.9","servers":[{"url":"https://cms.example"}]}`},
|
||||
{"/llms/gotchas.md", "text/markdown; charset=utf-8", "# Gotchas v9.9.9"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := llmsGet(handler, tc.target)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s answered %d", tc.target, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != tc.contentType {
|
||||
t.Errorf("%s content type %q, want %q", tc.target, got, tc.contentType)
|
||||
}
|
||||
if rec.Body.String() != tc.body {
|
||||
t.Errorf("%s body %q, want %q", tc.target, rec.Body.String(), tc.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlmsAnswers404WhenNothingWasGenerated(t *testing.T) {
|
||||
handler := llmsRouter(fstest.MapFS{})
|
||||
for _, target := range []string{"/llms.txt", "/llms-full.txt", "/openapi.json", "/llms/gotchas.md", "/llms/private.txt", "/llms/..%2Fllms.txt"} {
|
||||
rec := llmsGet(handler, target)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s answered %d, want 404", target, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "task build:llms") {
|
||||
t.Errorf("%s body %q does not name the task", target, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlmsOnlyServesMarkdownPages(t *testing.T) {
|
||||
handler := llmsRouter(fstest.MapFS{"llms/private.txt": {Data: []byte("not a page")}})
|
||||
if rec := llmsGet(handler, "/llms/private.txt"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("answered %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/mcpserver"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
target string
|
||||
auth string
|
||||
body string
|
||||
ctype string
|
||||
}
|
||||
|
||||
type mcpFixture struct {
|
||||
session *mcp.ClientSession
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
}
|
||||
|
||||
func (f *mcpFixture) last() recordedCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func newMCPFixture(t *testing.T, allowDestructive bool, respond func(w http.ResponseWriter, r *http.Request)) *mcpFixture {
|
||||
t.Helper()
|
||||
f := &mcpFixture{}
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, recordedCall{r.Method, r.URL.RequestURI(), r.Header.Get("Authorization"), string(raw), r.Header.Get("Content-Type")})
|
||||
f.mu.Unlock()
|
||||
respond(w, r)
|
||||
}))
|
||||
t.Cleanup(api.Close)
|
||||
server := mcpserver.New(mcpserver.NewClient(api.URL+"/", "rcms_key", nil), mcpserver.Options{Version: "test", AllowDestructive: allowDestructive})
|
||||
ctx := context.Background()
|
||||
serverSide, clientSide := mcp.NewInMemoryTransports()
|
||||
if _, err := server.Connect(ctx, serverSide, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, clientSide, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = session.Close() })
|
||||
f.session = session
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *mcpFixture) callTool(t *testing.T, name string, args map[string]any) *mcp.CallToolResult {
|
||||
t.Helper()
|
||||
res, err := f.session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolText(res *mcp.CallToolResult) string {
|
||||
return res.Content[0].(*mcp.TextContent).Text
|
||||
}
|
||||
|
||||
func echoStatus(status int, body string) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolsMapToTheAPIWithTheKey(t *testing.T) {
|
||||
f := newMCPFixture(t, false, echoStatus(200, `{"ok":true}`))
|
||||
cases := []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
}{
|
||||
{"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""},
|
||||
{"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""},
|
||||
{"create_article", map[string]any{"title": "Grout tips", "body": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
|
||||
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
|
||||
{"publish_article", map[string]any{"id": 7}, "PATCH", "/v1/article/7/publish", ""},
|
||||
{"check_seo", map[string]any{"id": 7}, "POST", "/v1/article/7/seo/check", `{}`},
|
||||
{"list_media", map[string]any{"filter": "unused"}, "GET", "/v1/media/?filter=unused", ""},
|
||||
{"list_sites", map[string]any{}, "GET", "/v1/site/", ""},
|
||||
{"get_sitemap", map[string]any{"key": "renew-blog", "size": 25}, "GET", "/v1/site/renew-blog/sitemap?size=25", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res := f.callTool(t, tc.tool, tc.args)
|
||||
if res.IsError || toolText(res) != `{"ok":true}` {
|
||||
t.Fatalf("%s: error=%v text=%q", tc.tool, res.IsError, toolText(res))
|
||||
}
|
||||
call := f.last()
|
||||
if call.method != tc.method || call.target != tc.target || !sameJSONBody(call.body, tc.body) || call.auth != "Bearer rcms_key" {
|
||||
t.Errorf("%s sent %q want %q", tc.tool, call.body, tc.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPReportsAPIErrorsAsToolErrors(t *testing.T) {
|
||||
f := newMCPFixture(t, false, echoStatus(403, "This action needs a real session."))
|
||||
res := f.callTool(t, "list_sites", map[string]any{})
|
||||
if !res.IsError || toolText(res) != "HTTP 403: This action needs a real session." {
|
||||
t.Fatalf("error=%v text=%q", res.IsError, toolText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPUploadsMultipartFromBase64(t *testing.T) {
|
||||
f := newMCPFixture(t, false, echoStatus(201, `{"id":1}`))
|
||||
res := f.callTool(t, "upload_media", map[string]any{"base64": base64.StdEncoding.EncodeToString([]byte("PNGDATA")), "filename": "wall.png"})
|
||||
if res.IsError {
|
||||
t.Fatal(toolText(res))
|
||||
}
|
||||
call := f.last()
|
||||
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") {
|
||||
t.Errorf("sent %+v", call)
|
||||
}
|
||||
if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError {
|
||||
t.Error("an upload without content must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDestructiveToolsNeedTheFlag(t *testing.T) {
|
||||
names := func(f *mcpFixture) map[string]bool {
|
||||
found := map[string]bool{}
|
||||
for tool, err := range f.session.Tools(context.Background(), nil) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found[tool.Name] = true
|
||||
}
|
||||
return found
|
||||
}
|
||||
safe := names(newMCPFixture(t, false, echoStatus(200, "")))
|
||||
if safe["delete_article"] || safe["delete_media"] || !safe["publish_article"] || len(safe) != 10 {
|
||||
t.Errorf("default tools: %v", safe)
|
||||
}
|
||||
f := newMCPFixture(t, true, echoStatus(204, ""))
|
||||
if all := names(f); !all["delete_article"] || !all["delete_media"] {
|
||||
t.Errorf("flagged tools: %v", all)
|
||||
}
|
||||
if res := f.callTool(t, "delete_article", map[string]any{"id": 3}); res.IsError || toolText(res) != "HTTP 204" || f.last().method != "DELETE" {
|
||||
t.Errorf("delete: %q %+v", toolText(res), f.last())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServesTheInstanceDocumentationAsAResource(t *testing.T) {
|
||||
f := newMCPFixture(t, false, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/llms-full.txt" {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "# RenewCMS documentation")
|
||||
})
|
||||
res, err := f.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "renewcms://docs/llms-full.txt"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Contents[0].Text != "# RenewCMS documentation" {
|
||||
t.Errorf("got %q", res.Contents[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func sameJSONBody(got string, want string) bool {
|
||||
if got == want {
|
||||
return true
|
||||
}
|
||||
var a, b any
|
||||
if json.Unmarshal([]byte(got), &a) != nil || json.Unmarshal([]byte(want), &b) != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user