306 lines
12 KiB
Go
306 lines
12 KiB
Go
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
|
|
}
|