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

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

No files matched your search

@@ -0,0 +1,77 @@
package mcpserver
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
requestTimeout = 60 * time.Second
maxResponseSize = 8 << 20
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
type Response struct {
Status int
Body string
}
func NewClient(baseURL string, apiKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: requestTimeout}
}
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: httpClient}
}
func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, body []byte, contentType string) (Response, error) {
target := c.baseURL + path
if len(query) > 0 {
target += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return Response{}, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
res, err := c.http.Do(req)
if err != nil {
return Response{}, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, maxResponseSize))
if err != nil {
return Response{}, err
}
return Response{Status: res.StatusCode, Body: string(raw)}, nil
}
func (r Response) Failed() bool {
return r.Status < 200 || r.Status > 299
}
func (r Response) Describe() string {
text := strings.TrimSpace(r.Body)
if r.Status == http.StatusNoContent || text == "" {
return fmt.Sprintf("HTTP %d", r.Status)
}
if r.Failed() {
return fmt.Sprintf("HTTP %d: %s", r.Status, text)
}
return text
}
+305
View File
@@ -0,0 +1,305 @@
package mcpserver
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
docsResourceURI = "renewcms://docs/llms-full.txt"
jsonType = "application/json"
)
type Options struct {
Version string
AllowDestructive bool
}
type server struct {
client *Client
}
type listArticlesInput struct {
Page int `json:"page,omitempty" jsonschema:"page number from 1"`
Size int `json:"size,omitempty" jsonschema:"page size: 5, 25, 50 or 100"`
Query string `json:"q,omitempty" jsonschema:"text matched in the title"`
Status string `json:"status,omitempty" jsonschema:"online or draft"`
Sort string `json:"sort,omitempty" jsonschema:"field:direction, e.g. title:asc or updated_at:desc"`
Site string `json:"site,omitempty" jsonschema:"site key"`
SEO string `json:"seo,omitempty" jsonschema:"ready, suggestions or hidden"`
Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"`
}
type getArticleInput struct {
ID int `json:"id" jsonschema:"article id"`
Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"`
}
type createArticleInput struct {
Title string `json:"title" jsonschema:"3 to 200 characters, cannot be changed later"`
Body string `json:"body" jsonschema:"HTML body"`
}
type updateArticleBodyInput struct {
ID int `json:"id" jsonschema:"article id"`
Body string `json:"body" jsonschema:"new HTML body, replaces the old one"`
}
type articleIDInput struct {
ID int `json:"id" jsonschema:"article id"`
}
type listMediaInput struct {
Query string `json:"q,omitempty" jsonschema:"search text"`
Filter string `json:"filter,omitempty" jsonschema:"all, unused or duplicates"`
Sort string `json:"sort,omitempty" jsonschema:"newest, oldest, name, largest or total_size"`
Page int `json:"page,omitempty" jsonschema:"page number from 1"`
Size int `json:"size,omitempty" jsonschema:"page size: 5, 25, 50 or 100"`
}
type uploadMediaInput struct {
Path string `json:"path,omitempty" jsonschema:"path of a local PNG, JPEG, WEBP or SVG file"`
Base64 string `json:"base64,omitempty" jsonschema:"base64 file content, instead of path"`
Filename string `json:"filename,omitempty" jsonschema:"file name, required with base64"`
}
type emptyInput struct{}
type sitemapInput struct {
Key string `json:"key" jsonschema:"site key"`
Page int `json:"page,omitempty" jsonschema:"page number from 1"`
Size int `json:"size,omitempty" jsonschema:"25, 50, 100 or 500"`
}
type mediaIDInput struct {
ID int `json:"id" jsonschema:"image id"`
}
func New(client *Client, opts Options) *mcp.Server {
s := &server{client: client}
srv := mcp.NewServer(&mcp.Implementation{Name: "renewcms", Version: opts.Version}, &mcp.ServerOptions{
Instructions: "Tools for a RenewCMS instance. Read the renewcms://docs/llms-full.txt resource for the API rules. Articles are created as drafts; ask before publishing.",
})
readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true}
additive := &mcp.ToolAnnotations{DestructiveHint: boolPtr(false)}
mcp.AddTool(srv, &mcp.Tool{Name: "list_articles", Description: "List one page of articles with total and counts.", Annotations: readOnly}, s.listArticles)
mcp.AddTool(srv, &mcp.Tool{Name: "get_article", Description: "Get one article.", Annotations: readOnly}, s.getArticle)
mcp.AddTool(srv, &mcp.Tool{Name: "create_article", Description: "Create a draft article.", Annotations: additive}, s.createArticle)
mcp.AddTool(srv, &mcp.Tool{Name: "update_article_body", Description: "Replace the body of an article. The previous body is overwritten.", Annotations: &mcp.ToolAnnotations{IdempotentHint: true}}, s.updateArticleBody)
mcp.AddTool(srv, &mcp.Tool{Name: "publish_article", Description: "Make an article visible to everyone.", Annotations: &mcp.ToolAnnotations{IdempotentHint: true, DestructiveHint: boolPtr(false)}}, s.publishArticle)
mcp.AddTool(srv, &mcp.Tool{Name: "check_seo", Description: "Check the SEO of the saved article and list suggestions.", Annotations: readOnly}, s.checkSEO)
mcp.AddTool(srv, &mcp.Tool{Name: "list_media", Description: "List one page of the image library.", Annotations: readOnly}, s.listMedia)
mcp.AddTool(srv, &mcp.Tool{Name: "upload_media", Description: "Upload an image from a local path or base64 content.", Annotations: additive}, s.uploadMedia)
mcp.AddTool(srv, &mcp.Tool{Name: "list_sites", Description: "List the sites.", Annotations: readOnly}, s.listSites)
mcp.AddTool(srv, &mcp.Tool{Name: "get_sitemap", Description: "Read the public sitemap of a site.", Annotations: readOnly}, s.getSitemap)
if opts.AllowDestructive {
mcp.AddTool(srv, &mcp.Tool{Name: "delete_article", Description: "Permanently delete an article."}, s.deleteArticle)
mcp.AddTool(srv, &mcp.Tool{Name: "delete_media", Description: "Permanently delete an image."}, s.deleteMedia)
}
srv.AddResource(&mcp.Resource{
URI: docsResourceURI,
Name: "llms-full.txt",
Description: "The full RenewCMS documentation served by the instance.",
MIMEType: "text/plain",
}, s.readDocs)
srv.AddPrompt(&mcp.Prompt{
Name: "write_and_publish_article",
Description: "Draft an article, check its SEO and publish it after confirmation.",
Arguments: []*mcp.PromptArgument{{Name: "topic", Description: "what the article is about", Required: true}},
}, writePrompt)
return srv
}
func boolPtr(value bool) *bool { return &value }
func intQuery(query url.Values, name string, value int) {
if value > 0 {
query.Set(name, strconv.Itoa(value))
}
}
func textQuery(query url.Values, name string, value string) {
if value != "" {
query.Set(name, value)
}
}
func result(res Response, err error) (*mcp.CallToolResult, any, error) {
if err != nil {
return nil, nil, err
}
return &mcp.CallToolResult{
IsError: res.Failed(),
Content: []mcp.Content{&mcp.TextContent{Text: res.Describe()}},
}, nil, nil
}
func (s *server) call(ctx context.Context, method string, path string, query url.Values, body any) (*mcp.CallToolResult, any, error) {
var raw []byte
contentType := ""
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, nil, err
}
raw, contentType = encoded, jsonType
}
return result(s.client.Do(ctx, method, path, query, raw, contentType))
}
func articlePath(id int, suffix string) string {
return "/v1/article/" + strconv.Itoa(id) + suffix
}
func (s *server) listArticles(ctx context.Context, _ *mcp.CallToolRequest, in listArticlesInput) (*mcp.CallToolResult, any, error) {
query := url.Values{}
intQuery(query, "page", in.Page)
intQuery(query, "size", in.Size)
textQuery(query, "q", in.Query)
textQuery(query, "status", in.Status)
textQuery(query, "sort", in.Sort)
textQuery(query, "site", in.Site)
textQuery(query, "seo", in.SEO)
textQuery(query, "format", in.Format)
return s.call(ctx, http.MethodGet, "/v1/article/", query, nil)
}
func (s *server) getArticle(ctx context.Context, _ *mcp.CallToolRequest, in getArticleInput) (*mcp.CallToolResult, any, error) {
query := url.Values{}
textQuery(query, "format", in.Format)
return s.call(ctx, http.MethodGet, articlePath(in.ID, ""), query, nil)
}
func (s *server) createArticle(ctx context.Context, _ *mcp.CallToolRequest, in createArticleInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodPost, "/v1/article/", nil, map[string]string{"title": in.Title, "body": in.Body})
}
func (s *server) updateArticleBody(ctx context.Context, _ *mcp.CallToolRequest, in updateArticleBodyInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodPatch, articlePath(in.ID, ""), nil, map[string]string{"body": in.Body})
}
func (s *server) publishArticle(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodPatch, articlePath(in.ID, "/publish"), nil, nil)
}
func (s *server) checkSEO(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodPost, articlePath(in.ID, "/seo/check"), nil, map[string]string{})
}
func (s *server) deleteArticle(ctx context.Context, _ *mcp.CallToolRequest, in articleIDInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodDelete, articlePath(in.ID, ""), nil, nil)
}
func (s *server) listMedia(ctx context.Context, _ *mcp.CallToolRequest, in listMediaInput) (*mcp.CallToolResult, any, error) {
query := url.Values{}
textQuery(query, "q", in.Query)
textQuery(query, "filter", in.Filter)
textQuery(query, "sort", in.Sort)
intQuery(query, "page", in.Page)
intQuery(query, "size", in.Size)
return s.call(ctx, http.MethodGet, "/v1/media/", query, nil)
}
func (s *server) deleteMedia(ctx context.Context, _ *mcp.CallToolRequest, in mediaIDInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodDelete, "/v1/media/"+strconv.Itoa(in.ID), nil, nil)
}
func (s *server) uploadMedia(ctx context.Context, _ *mcp.CallToolRequest, in uploadMediaInput) (*mcp.CallToolResult, any, error) {
name, content, err := uploadContent(in)
if err != nil {
return nil, nil, err
}
var form bytes.Buffer
writer := multipart.NewWriter(&form)
part, err := writer.CreateFormFile("file", name)
if err != nil {
return nil, nil, err
}
if _, err := part.Write(content); err != nil {
return nil, nil, err
}
if err := writer.Close(); err != nil {
return nil, nil, err
}
return result(s.client.Do(ctx, http.MethodPost, "/v1/media/", nil, form.Bytes(), writer.FormDataContentType()))
}
func uploadContent(in uploadMediaInput) (string, []byte, error) {
switch {
case in.Path != "" && in.Base64 != "":
return "", nil, fmt.Errorf("give either path or base64, not both")
case in.Path != "":
content, err := os.ReadFile(in.Path)
if err != nil {
return "", nil, err
}
name := in.Filename
if name == "" {
name = filepath.Base(in.Path)
}
return name, content, nil
case in.Base64 != "":
if in.Filename == "" {
return "", nil, fmt.Errorf("filename is required with base64")
}
content, err := base64.StdEncoding.DecodeString(in.Base64)
if err != nil {
return "", nil, fmt.Errorf("base64 content is not valid: %w", err)
}
return in.Filename, content, nil
}
return "", nil, fmt.Errorf("give a path or base64 content")
}
func (s *server) listSites(ctx context.Context, _ *mcp.CallToolRequest, _ emptyInput) (*mcp.CallToolResult, any, error) {
return s.call(ctx, http.MethodGet, "/v1/site/", nil, nil)
}
func (s *server) getSitemap(ctx context.Context, _ *mcp.CallToolRequest, in sitemapInput) (*mcp.CallToolResult, any, error) {
query := url.Values{}
intQuery(query, "page", in.Page)
intQuery(query, "size", in.Size)
return s.call(ctx, http.MethodGet, "/v1/site/"+url.PathEscape(in.Key)+"/sitemap", query, nil)
}
func (s *server) readDocs(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
res, err := s.client.Do(ctx, http.MethodGet, "/llms-full.txt", nil, nil, "")
if err != nil {
return nil, err
}
if res.Failed() {
return nil, fmt.Errorf("the instance did not serve its documentation: %s", res.Describe())
}
return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{
URI: req.Params.URI,
MIMEType: "text/plain",
Text: res.Body,
}}}, nil
}
func writePrompt(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
text := "Write an article about " + req.Params.Arguments["topic"] + " for this RenewCMS instance. " +
"Create it as a draft with create_article, run check_seo and fix what is worth fixing with update_article_body, " +
"then show me the result and ask before calling publish_article."
return &mcp.GetPromptResult{
Description: "Draft, check and publish an article",
Messages: []*mcp.PromptMessage{{Role: "user", Content: &mcp.TextContent{Text: text}}},
}, nil
}