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 }