Files
sm-journal-plugin/mcp.go
Jakub Zych 4ab94da66c feat: MCP tools for posts, categories and tags, the draft_post prompt
- journal.Plugin.MCP: list_posts, show_post, list_categories, list_tags,
  create_post, update_post, delete_post and the draft_post prompt, guard
  backend, listed with golem15.journal.access_posts; update fields are
  optional so only sent keys reach the route
- optionalBackendPrincipal honours the MCP dispatch marker (no bearer
  short-circuit; anonymous reads unchanged)
- require summercms v0.4.4
- TestJournalMCPSmoke, TestOptionalBackendPrincipalDispatch
2026-10-23 12:54:03 +02:00

186 lines
8.1 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"`
}
// 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 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,
},
},
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},
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
}