- POST /_journal/api/v1/images/alt {dry_run, changes} sets alt on the
matching img of default or translated content through the tokenizer,
all or nothing (422), with a RecordSaved per changed post
- MCP journal_get_settings, journal_update_settings, journal_fix_headings
(manage_settings) and journal_set_image_alt (access_posts, destructive)
- journal:fix-headings help names its output and the refusal
276 lines
13 KiB
Go
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.",
|
|
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
|
|
}
|