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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user