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
|
AGENTS.md
|
||||||
codex-workflow.md
|
codex-workflow.md
|
||||||
CODING_STANDARDS.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
|
RUN bun install --frozen-lockfile
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN bun run build-only
|
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
|
FROM golang:1.27.3-alpine AS server
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
|
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
|
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|||||||
+17
-1
@@ -3,6 +3,7 @@ version: '3'
|
|||||||
vars:
|
vars:
|
||||||
BINARY: renewcms
|
BINARY: renewcms
|
||||||
DIST_DIR: internal/infrastructure/assets/dist
|
DIST_DIR: internal/infrastructure/assets/dist
|
||||||
|
LLMS_DIR: internal/infrastructure/llmdocs/generated
|
||||||
VERSION:
|
VERSION:
|
||||||
sh: git describe --tags --always --dirty
|
sh: git describe --tags --always --dirty
|
||||||
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
||||||
@@ -32,6 +33,7 @@ tasks:
|
|||||||
|
|
||||||
dev:api:
|
dev:api:
|
||||||
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
||||||
|
deps: [build:llms]
|
||||||
cmds:
|
cmds:
|
||||||
- air
|
- air
|
||||||
|
|
||||||
@@ -236,17 +238,31 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- bun run build
|
- 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:
|
build:
|
||||||
desc: Production binary for this machine → bin/{{.BINARY}}
|
desc: Production binary for this machine → bin/{{.BINARY}}
|
||||||
deps: [build:web]
|
deps: [build:web, build:llms]
|
||||||
cmds:
|
cmds:
|
||||||
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
|
- 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:
|
release:
|
||||||
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
||||||
cmds:
|
cmds:
|
||||||
- task: release:check
|
- task: release:check
|
||||||
- task: build:web
|
- task: build:web
|
||||||
|
- task: build:llms
|
||||||
- for: { var: RELEASE_TARGETS }
|
- for: { var: RELEASE_TARGETS }
|
||||||
task: build:target
|
task: build:target
|
||||||
vars:
|
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/entry"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
"RenewCMS/api/handlers/imagevariant"
|
"RenewCMS/api/handlers/imagevariant"
|
||||||
|
"RenewCMS/api/handlers/llms"
|
||||||
"RenewCMS/api/handlers/media"
|
"RenewCMS/api/handlers/media"
|
||||||
"RenewCMS/api/handlers/settings"
|
"RenewCMS/api/handlers/settings"
|
||||||
"RenewCMS/api/handlers/site"
|
"RenewCMS/api/handlers/site"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
"RenewCMS/api/pkg/authtoken"
|
"RenewCMS/api/pkg/authtoken"
|
||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"RenewCMS/internal/infrastructure/llmdocs"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -493,6 +495,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
|
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
|
||||||
apiRouter.Use(chimiddleware.GetHead)
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
apiRouter.Get("/healthz", GetHealth)
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
|
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
|
||||||
apiRouter.Mount("/v1", backend)
|
apiRouter.Mount("/v1", backend)
|
||||||
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||||
apiRouter.Mount("/", frontend)
|
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/go-sql-driver/mysql v1.10.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.11.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/crypto v0.58.0
|
||||||
golang.org/x/image v0.47.0
|
golang.org/x/image v0.47.0
|
||||||
golang.org/x/net v0.61.0
|
golang.org/x/net v0.61.0
|
||||||
@@ -45,6 +46,7 @@ require (
|
|||||||
github.com/glebarez/go-sqlite v1.23.0 // indirect
|
github.com/glebarez/go-sqlite v1.23.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.2 // indirect
|
github.com/go-playground/locales v0.14.2 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.3 // 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/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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/sync v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.49.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/libc v1.79.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.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-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 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
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 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
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=
|
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-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
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=
|
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/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 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
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 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
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 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
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 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
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 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
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=
|
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
|
bodySample: Record<string, unknown> | null
|
||||||
errors: ApiError[]
|
errors: ApiError[]
|
||||||
response: string
|
response: string
|
||||||
|
status: number
|
||||||
rateLimited: boolean
|
rateLimited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +543,7 @@ function endpoint(
|
|||||||
bodySample: null,
|
bodySample: null,
|
||||||
errors,
|
errors,
|
||||||
response,
|
response,
|
||||||
|
status: response === NO_CONTENT_RESPONSE ? 204 : 200,
|
||||||
rateLimited: false,
|
rateLimited: false,
|
||||||
...extra,
|
...extra,
|
||||||
}
|
}
|
||||||
@@ -638,6 +640,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
title: 'Choosing tiles for a small bathroom',
|
title: 'Choosing tiles for a small bathroom',
|
||||||
body: 'Small rooms show every grout line.',
|
body: 'Small rooms show every grout line.',
|
||||||
},
|
},
|
||||||
|
status: 201,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
@@ -886,6 +889,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
json(mediaItem()),
|
json(mediaItem()),
|
||||||
{
|
{
|
||||||
pathId: null,
|
pathId: null,
|
||||||
|
status: 201,
|
||||||
bodyKind: 'multipart',
|
bodyKind: 'multipart',
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
@@ -1156,6 +1160,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
],
|
],
|
||||||
json(invite()),
|
json(invite()),
|
||||||
{
|
{
|
||||||
|
status: 201,
|
||||||
bodyKind: 'json',
|
bodyKind: 'json',
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export default defineConfig({
|
|||||||
'/v1': 'http://localhost:8080',
|
'/v1': 'http://localhost:8080',
|
||||||
'/healthz': 'http://localhost:8080',
|
'/healthz': 'http://localhost:8080',
|
||||||
'/static': '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: [
|
plugins: [
|
||||||
|
|||||||
Reference in new issue
Block a user