Files
sm-journal-plugin/mcp.go
Jakub Zych 7662d54ab3 fix: image alt texts on markdown posts are written into the markdown
POST /_journal/api/v1/images/alt writes the alt of a markdown post into
its source: the inline image ![alt](src) outside code
(classes.SetMarkdownImageAlt) or a raw img outside code
(classes.SetImageAltOutsideCode), so the next content save keeps it. An
image the markdown does not hold that way is refused with 422
changes.N.src and nothing is written.
2026-11-04 23:20:02 +01:00

276 lines
13 KiB
Go

package journal
import (
"context"
"fmt"
"net/http"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
var _ pact.HasMCP = (*Plugin)(nil)
// mcpGuard is the guard the Journal tools run as: the backend account linked
// to the MCP user (the routes resolve the backend principal themselves).
const mcpGuard = "backend"
// mcpAccessPosts is the permission every Journal tool and the prompt are
// listed with (the routes check it, and access_publish, themselves).
const mcpAccessPosts = "golem15.journal.access_posts"
// mcpListPostsInput is the query of GET /_journal/api/v1/posts.
type mcpListPostsInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"posts per page"`
Search string `json:"search,omitempty" query:"search" jsonschema:"a phrase of at least three characters the title, slug, excerpt or content contains"`
Category string `json:"category,omitempty" query:"category" jsonschema:"a category slug or id"`
Tag string `json:"tag,omitempty" query:"tag" jsonschema:"a tag slug or id"`
Author string `json:"author,omitempty" query:"author" jsonschema:"the id of the administrator who wrote the posts"`
Published *bool `json:"published,omitempty" query:"published" jsonschema:"true for published posts only, false for drafts too (the default for an editor)"`
Sort string `json:"sort,omitempty" query:"sort" jsonschema:"the order: a column (published_at, created_at, updated_at, title or id) and optionally asc, for example title asc; newest published first by default"`
}
// mcpShowPostInput names one post by slug.
type mcpShowPostInput struct {
Slug string `json:"slug" path:"slug" jsonschema:"the post slug"`
}
// mcpCreatePostInput is the body of POST /_journal/api/v1/posts.
type mcpCreatePostInput struct {
Title string `json:"title" jsonschema:"the post title"`
Content string `json:"content" jsonschema:"the post body (Markdown)"`
Slug string `json:"slug,omitempty" jsonschema:"the URL slug; derived from the title when omitted"`
Excerpt string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
Published *bool `json:"published,omitempty" jsonschema:"publish at once (needs the publish permission); a draft when omitted"`
PublishedAt string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
Categories []uint `json:"categories,omitempty" jsonschema:"category ids"`
Tags []string `json:"tags,omitempty" jsonschema:"tag names; unknown tags are created"`
}
// mcpUpdatePostInput is PUT /_journal/api/v1/posts/{id}: only the fields sent
// are changed.
type mcpUpdatePostInput struct {
ID uint `json:"id" path:"id" jsonschema:"the post id"`
Title *string `json:"title,omitempty" jsonschema:"the post title"`
Content *string `json:"content,omitempty" jsonschema:"the post body (Markdown)"`
Slug *string `json:"slug,omitempty" jsonschema:"the URL slug"`
Excerpt *string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
Published *bool `json:"published,omitempty" jsonschema:"published (needs the publish permission) or a draft"`
PublishedAt *string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
Categories *[]uint `json:"categories,omitempty" jsonschema:"category ids; replaces the post's categories"`
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names; replaces the post's tags, unknown tags are created"`
}
// mcpDeletePostInput names one post by id.
type mcpDeletePostInput struct {
ID uint `json:"id" path:"id" jsonschema:"the post id"`
}
// mcpManageSettings is the permission of the settings and heading tools.
const mcpManageSettings = "golem15.journal.manage_settings"
// mcpUpdateSettingsInput is PUT /_journal/api/v1/settings: only the fields
// sent are changed.
type mcpUpdateSettingsInput struct {
ContentH1ToH2 *bool `json:"content_h1_to_h2,omitempty" jsonschema:"convert H1 to H2 in post and category content on every save; the editor then offers H2 to H4 (switching it never rewrites stored content, journal_fix_headings does)"`
GenericAnchors *string `json:"generic_anchors,omitempty" jsonschema:"the link texts the editor warns about, one per line"`
ShowAllPosts *bool `json:"show_all_posts,omitempty" jsonschema:"show drafts to administrators on the site"`
UseRichEditor *bool `json:"use_rich_editor,omitempty" jsonschema:"use the rich editor for post content"`
SearchUseTypesense *bool `json:"search_use_typesense,omitempty" jsonschema:"search posts with the configured search engine"`
SearchTitleWeight *int `json:"search_title_weight,omitempty" jsonschema:"relevance weight of the title, 0 to 100"`
SearchExcerptWeight *int `json:"search_excerpt_weight,omitempty" jsonschema:"relevance weight of the excerpt, 0 to 100"`
SearchContentWeight *int `json:"search_content_weight,omitempty" jsonschema:"relevance weight of the content, 0 to 100"`
RSSEnabled *bool `json:"rss_enabled,omitempty" jsonschema:"serve the RSS feed"`
RSSIncludeContent *bool `json:"rss_include_content,omitempty" jsonschema:"include the full content in the feed"`
RSSTitle *string `json:"rss_title,omitempty" jsonschema:"the feed title"`
RSSDescription *string `json:"rss_description,omitempty" jsonschema:"the feed description"`
RSSLanguage *string `json:"rss_language,omitempty" jsonschema:"the feed language code, for example en-us"`
RSSCopyright *string `json:"rss_copyright,omitempty" jsonschema:"the feed copyright notice"`
RSSImageURL *string `json:"rss_image_url,omitempty" jsonschema:"the feed image URL"`
RSSPostsPerFeed *int `json:"rss_posts_per_feed,omitempty" jsonschema:"posts in the feed, 1 to 100"`
}
// mcpFixHeadingsInput is POST /_journal/api/v1/fix-headings.
type mcpFixHeadingsInput struct {
DryRun *bool `json:"dry_run,omitempty" jsonschema:"only count the H1 headings (the default); false converts and saves them"`
}
// mcpImageAltChange is one alt change of journal_set_image_alt.
type mcpImageAltChange struct {
PostID uint `json:"post_id" jsonschema:"the post id"`
Locale string `json:"locale,omitempty" jsonschema:"the content locale; the default locale when omitted"`
Src string `json:"src" jsonschema:"the src of the image in the post content, exactly as stored"`
Alt string `json:"alt" jsonschema:"the alt text; empty marks the image as decorative"`
}
// mcpSetImageAltInput is POST /_journal/api/v1/images/alt.
type mcpSetImageAltInput struct {
DryRun *bool `json:"dry_run,omitempty" jsonschema:"show the changes without saving them"`
Changes []mcpImageAltChange `json:"changes" jsonschema:"the alt changes; all are refused when one names an image that is not in its post"`
}
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
// category and tag routes of /_journal/api/v1, run as the backend account
// linked to the MCP user and listed only when that account has
// golem15.journal.access_posts; the settings, fix_headings and
// set_image_alt tools of the content fixes (D-10, D-85) are listed with
// golem15.journal.manage_settings (set_image_alt with access_posts). The
// routes enforce their own permissions (publishing, other authors' posts)
// on every call. Media and featured-image uploads (multipart) are not
// tools.
func (p *Plugin) MCP() pact.MCP {
destructive := true
return pact.MCP{
Tools: []pact.MCPTool{
{
Name: "list_posts",
Title: "List posts",
Description: "Lists Journal posts, drafts included, newest first, with filters for a phrase, category, tag and author.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/posts",
Input: mcpListPostsInput{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "show_post",
Title: "Show post",
Description: "Shows one Journal post by slug, a draft included.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/posts/{slug}",
Input: mcpShowPostInput{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "list_categories",
Title: "List categories",
Description: "Lists the Journal categories with their ids and slugs.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/categories",
Input: struct{}{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "list_tags",
Title: "List tags",
Description: "Lists the Journal tags with their ids and slugs.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/tags",
Input: struct{}{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "create_post",
Title: "Create post",
Description: "Creates a Journal post; a draft unless published is true.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_journal/api/v1/posts",
Input: mcpCreatePostInput{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "update_post",
Title: "Update post",
Description: "Changes the fields sent of one Journal post by id.",
Guard: mcpGuard,
Method: http.MethodPut,
Path: "/_journal/api/v1/posts/{id}",
Input: mcpUpdatePostInput{},
Permissions: []string{mcpAccessPosts},
},
{
Name: "delete_post",
Title: "Delete post",
Description: "Deletes one Journal post by id.",
Guard: mcpGuard,
Method: http.MethodDelete,
Path: "/_journal/api/v1/posts/{id}",
Input: mcpDeletePostInput{},
Permissions: []string{mcpAccessPosts},
Destructive: &destructive,
},
{
Name: "get_settings",
Title: "Get Journal settings",
Description: "Shows the Journal settings: the H1 to H2 conversion, the generic link texts, search and RSS.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_journal/api/v1/settings",
Input: struct{}{},
Permissions: []string{mcpManageSettings},
},
{
Name: "update_settings",
Title: "Update Journal settings",
Description: "Changes the Journal settings sent, such as the H1 to H2 conversion or the generic link texts.",
Guard: mcpGuard,
Method: http.MethodPut,
Path: "/_journal/api/v1/settings",
Input: mcpUpdateSettingsInput{},
Permissions: []string{mcpManageSettings},
},
{
Name: "fix_headings",
Title: "Fix stored H1 headings",
Description: "Converts H1 headings to H2 in stored post and category content, like journal:fix-headings; a dry run unless dry_run is false. Refused while the H1 setting is off.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_journal/api/v1/fix-headings",
Input: mcpFixHeadingsInput{},
Permissions: []string{mcpManageSettings},
},
{
Name: "set_image_alt",
Title: "Set image alt texts",
Description: "Sets the alt text of images in post content, several at once; an empty alt marks an image as decorative. In a markdown post the alt is written into the markdown source (an inline image ![alt](src), or a raw img tag outside code); an image the markdown does not hold that way (a reference-style image) is refused with changes.N.src: edit that post instead.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_journal/api/v1/images/alt",
Input: mcpSetImageAltInput{},
Permissions: []string{mcpAccessPosts},
Destructive: &destructive,
},
},
Prompts: []pact.MCPPrompt{{
Name: "draft_post",
Title: "Draft a post",
Description: "Drafts a Journal post on a topic and saves it as a draft.",
Guard: mcpGuard,
Permissions: []string{mcpAccessPosts},
// It directs journal_create_post, so a read-only grant does
// not see it.
Writes: true,
Arguments: []pact.MCPPromptArgument{
{Name: "topic", Description: "what the post is about", Required: true},
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
},
Render: renderDraftPost,
}},
}
}
// renderDraftPost builds the draft_post prompt's one user message.
func renderDraftPost(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
topic := strings.TrimSpace(args["topic"])
if topic == "" {
return nil, fmt.Errorf("draft_post: topic is required")
}
tone := strings.TrimSpace(args["tone"])
if tone == "" {
tone = "clear and friendly"
}
text := "Write a Journal post about: " + topic + "\n\n" +
"Tone: " + tone + ".\n" +
"Style: a short, specific title; an excerpt of one or two sentences; the body in Markdown with short paragraphs and subheadings where they help; no filler.\n" +
"Before writing, call journal_list_categories and journal_list_tags and reuse fitting categories and tags.\n" +
"Then save it with journal_create_post with published set to false, so an editor reviews the draft before it goes live, and reply with the new post's slug."
return []pact.MCPPromptMessage{{Role: "user", Text: text}}, nil
}