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}, // 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 }