feat: MCP tools for settings, headings and image alt

- 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
This commit is contained in:
Jakub Zych
2026-11-04 10:10:52 +01:00
parent 5210b64e0f
commit af25eaa677
10 changed files with 597 additions and 15 deletions

92
classes/image_alt.go Normal file
View File

@@ -0,0 +1,92 @@
package classes
import (
"bytes"
"strings"
xhtml "golang.org/x/net/html"
)
// SetImageAlt sets the alt attribute of every img in html whose src
// attribute (unescaped) equals src, adding it when missing. It returns the
// new HTML, the number of matching images and the alt the first match had
// (nil when it had none). It works on the x/net/html tokenizer: a matching
// img tag is written again from its token, so every attribute value,
// alt included, is escaped by the tokenizer and never concatenated; every
// other byte of html is copied as it is. An empty alt marks a decorative
// image (alt="").
func SetImageAlt(html, src, alt string) (string, int, *string) {
if src == "" || !containsFold(html, "<img") {
return html, 0, nil
}
z := xhtml.NewTokenizer(strings.NewReader(html))
var b bytes.Buffer
b.Grow(len(html) + len(alt))
matches := 0
var old *string
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
break
}
raw := z.Raw()
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
// Raw is copied before Token, which reads the tag; Token is
// the only reader of the tag (TagName would consume it).
kept := append([]byte(nil), raw...)
tok := z.Token()
if tok.Data == "img" && imgSrc(tok) == src {
prev := setAlt(&tok, alt)
if matches == 0 {
old = prev
}
matches++
b.WriteString(tok.String())
continue
}
b.Write(kept)
continue
}
b.Write(raw)
}
if matches == 0 {
return html, 0, nil
}
return b.String(), matches, old
}
func imgSrc(tok xhtml.Token) string {
for _, a := range tok.Attr {
if a.Namespace == "" && a.Key == "src" {
return a.Val
}
}
return ""
}
// setAlt sets tok's alt to alt and returns the previous value (nil when
// there was none). Repeated alt attributes collapse to one.
func setAlt(tok *xhtml.Token, alt string) *string {
var prev *string
attrs := tok.Attr[:0]
set := false
for _, a := range tok.Attr {
if a.Namespace == "" && a.Key == "alt" {
if prev == nil {
v := a.Val
prev = &v
}
if set {
continue
}
a.Val = alt
set = true
}
attrs = append(attrs, a)
}
if !set {
attrs = append(attrs, xhtml.Attribute{Key: "alt", Val: alt})
}
tok.Attr = attrs
return prev
}

22
classes/image_alt_test.go Normal file
View File

@@ -0,0 +1,22 @@
package classes
import "testing"
// TestSetImageAlt pins the alt rewrite: only the matching img tag is
// rewritten from its token, the alt is escaped, a missing alt is added and
// every other byte is kept (T-33-04).
func TestSetImageAlt(t *testing.T) {
const doc = `<p class="a">x &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;y=2"><img src="b.jpg" alt="old"/>`
out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" <b>`)
want := `<p class="a">x &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;y=2" alt="Lion&#39;s &#34;mane&#34; &lt;b&gt;"><img src="b.jpg" alt="old"/>`
if out != want || n != 1 || old != nil {
t.Fatalf("SetImageAlt = %q, %d, %v; want %q, 1, nil", out, n, old, want)
}
out, n, old = SetImageAlt(doc, "b.jpg", "")
if out != `<p class="a">x &amp; y</p><img src="https://cdn.example.test/a.jpg?x=1&amp;y=2"><img src="b.jpg" alt=""/>` || n != 1 || old == nil || *old != "old" {
t.Fatalf("decorative = %q, %d, %v", out, n, old)
}
if out, n, _ := SetImageAlt(doc, "missing.jpg", "x"); out != doc || n != 0 {
t.Fatalf("missing = %q, %d", out, n)
}
}

View File

@@ -13,7 +13,13 @@ import (
// FixHeadings is journal:fix-headings: it converts H1 to H2 in stored
// Journal rich text (post content in every locale and category pillar text)
// while the Journal H1 setting is on. It is a dry run unless --apply is
// given; with the setting off it refuses with exit code 1.
// given; with the setting off it refuses with exit code 1. Its output (UI-SPEC
// C27 with the P33-54 wording, English only):
//
// - dry run: "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save."
// - applied: "Converted :count H1 headings in :posts posts and :categories categories."
// - nothing to do: "No H1 headings found in post or category content."
// - refused (classes.H1ConversionOffText): "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
func FixHeadings(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:fix-headings",
@@ -28,8 +34,10 @@ categories. Run with --apply to save."). With --apply it converts them in
one transaction, fires a record event per changed post and category (so an
SEO or search plugin hears about it) and queues the site cache purge.
While the H1 setting is off the command refuses and exits with code 1. The
MCP tool journal_fix_headings runs the same conversion.`,
While the H1 setting is off the command prints "H1 to H2 conversion is
turned off in the Journal settings (Headings and links). Turn it on first, or
keep the H1 headings." and exits with code 1. The MCP tool
journal_fix_headings runs the same conversion.`,
Flags: []bonfire.Flag{
{Name: "apply", Description: "Save the converted content (default: dry run)", Bare: true},
{Name: "dry-run", Description: "Only count the H1 headings (the default)", Bare: true},

View File

@@ -16,8 +16,8 @@ import (
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"gorm.io/gorm"
)

View File

@@ -1,13 +1,30 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxImageAltChanges caps one POST /images/alt batch.
const maxImageAltChanges = 500
// FixHeadings is POST /_journal/api/v1/fix-headings {dry_run}: the
// journal:fix-headings conversion of stored H1 headings (posts in every
// locale and category pillar text), for golem15.journal.manage_settings. It
@@ -51,3 +68,246 @@ func FixHeadings(app *backpack.App) http.HandlerFunc {
})
}
}
// imageAltChange is one requested alt change.
type imageAltChange struct {
postID uint
locale string
src string
alt string
}
// imageAltText is the stored body of one post in one locale ("" is the
// host columns) while a batch is applied.
type imageAltText struct {
post *models.Post
locale string
content, contentHTML string
oldContent, oldHTML string
}
// errForbiddenPost stops a batch that names a post the caller may not edit.
var errForbiddenPost = errors.New("forbidden post")
// SetImageAlt is POST /_journal/api/v1/images/alt {dry_run, changes:
// [{post_id, locale, src, alt}]}: it sets the alt attribute of the img whose
// src equals src in the post's content_html (and in content when that holds
// the same image), in the default locale when locale is empty and in that
// translation otherwise; alt "" marks a decorative image. It needs
// golem15.journal.access_posts and may edit only the posts the caller may
// edit (403). The batch is all or nothing: a missing post or an src that is
// not in the post refuses it with 422 (errors keyed changes.N.field). It
// answers {changes: [{post_id, locale, src, old_alt, alt, changed}],
// dry_run}; a dry run (dry_run true) writes nothing. Applied changes write
// in one transaction and fire one RecordSaved per changed post after the
// commit. Alt text is post content, so it is a Journal tool (P33-05):
// MCP journal_set_image_alt.
func SetImageAlt(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok || !requireAccessPosts(w, pr) {
return
}
db, err := gdb(app)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
body, err := readJSONObject(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
return
}
dry := false
if v, has := body["dry_run"]; has && v != nil {
dry = asBool(v)
}
changes, verrs := parseImageAltChanges(body["changes"])
if len(verrs) > 0 {
writeValidation(w, verrs)
return
}
var answer []map[string]any
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
texts := map[string]*imageAltText{}
posts := map[uint]*models.Post{}
answer = make([]map[string]any, 0, len(changes))
for i, c := range changes {
post, found := posts[c.postID]
if !found {
p, err := findPostByID(tx, strconv.FormatUint(uint64(c.postID), 10))
if err != nil || p == nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The selected post is invalid.")
continue
}
if !p.CanEdit(pr) {
return errForbiddenPost
}
posts[c.postID], post = p, p
}
key := strconv.FormatUint(uint64(c.postID), 10) + "|" + c.locale
text, found := texts[key]
if !found {
text, err = loadImageAltText(ctx, tx, post, c.locale)
if err != nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.locale", i), "The locale is invalid.")
continue
}
texts[key] = text
}
html, n, old := classes.SetImageAlt(text.contentHTML, c.src, c.alt)
if n == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The image is not in the post.")
continue
}
text.contentHTML = html
if content, m, _ := classes.SetImageAlt(text.content, c.src, c.alt); m > 0 {
text.content = content
}
var oldAlt any
if old != nil {
oldAlt = *old
}
answer = append(answer, map[string]any{
"post_id": c.postID, "locale": c.locale, "src": c.src,
"old_alt": oldAlt, "alt": c.alt, "changed": old == nil || *old != c.alt,
})
}
if len(verrs) > 0 || dry {
return nil
}
return writeImageAltTexts(ctx, app, tx, texts)
})
switch {
case errors.Is(err, errForbiddenPost):
writeAPIError(w, http.StatusForbidden, errEditPermission)
return
case err != nil:
pluginLog.ErrorContext(r.Context(), "journal api: image alt failed", "error", err)
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
case len(verrs) > 0:
writeValidation(w, verrs)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"changes": answer, "dry_run": dry})
}
}
// parseImageAltChanges reads the changes array: post_id (a positive id),
// src (not blank) and alt (a string, "" allowed) are required, locale is
// optional.
func parseImageAltChanges(raw any) ([]imageAltChange, map[string][]string) {
items, ok := raw.([]any)
if !ok {
return nil, map[string][]string{"changes": {"The changes must be an array."}}
}
if len(items) > maxImageAltChanges {
return nil, map[string][]string{"changes": {fmt.Sprintf("The changes may not have more than %d items.", maxImageAltChanges)}}
}
var verrs map[string][]string
out := make([]imageAltChange, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]any)
if !ok {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d", i), "The change must be an object.")
continue
}
var c imageAltChange
id, ok := asUint(m["post_id"])
if !ok || id == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The post id field is required.")
}
c.postID = id
c.src = strings.TrimSpace(asString(m["src"]))
if c.src == "" {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The src field is required.")
}
alt, isString := m["alt"].(string)
if !isString {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.alt", i), "The alt must be a string.")
}
c.alt = strings.TrimSpace(alt)
c.locale = strings.TrimSpace(asString(m["locale"]))
out = append(out, c)
}
return out, verrs
}
// loadImageAltText reads a post's content and content_html in locale: the
// host columns for "", the translation otherwise (the host columns for the
// default locale).
func loadImageAltText(ctx context.Context, tx *gorm.DB, post *models.Post, locale string) (*imageAltText, error) {
t := &imageAltText{post: post, locale: locale}
if locale == "" {
t.content, t.contentHTML = post.Content, post.ContentHTML
} else {
for _, field := range []string{"content", "content_html"} {
v, _, err := translate.TranslatedExact(ctx, tx, post, field, locale)
if err != nil {
return nil, err
}
text, _ := v.(string)
if field == "content" {
t.content = text
} else {
t.contentHTML = text
}
}
}
t.oldContent, t.oldHTML = t.content, t.contentHTML
return t, nil
}
// writeImageAltTexts stores the changed bodies and fires one RecordSaved
// per changed post, with a site cache purge.
func writeImageAltTexts(ctx context.Context, app *backpack.App, tx *gorm.DB, texts map[string]*imageAltText) error {
keys := make([]string, 0, len(texts))
for key := range texts {
keys = append(keys, key)
}
sort.Strings(keys)
byPost := map[uint][]pact.RecordChange{}
var order []*models.Post
for _, key := range keys {
t := texts[key]
var changes []pact.RecordChange
if t.content != t.oldContent {
changes = append(changes, pact.RecordChange{Attribute: "content", Locale: t.locale, Old: t.oldContent, New: t.content})
}
if t.contentHTML != t.oldHTML {
changes = append(changes, pact.RecordChange{Attribute: "content_html", Locale: t.locale, Old: t.oldHTML, New: t.contentHTML})
}
if len(changes) == 0 {
continue
}
if t.locale == "" {
if err := tx.Model(&models.Post{}).Where("id = ?", t.post.ID).
Updates(map[string]any{"content": t.content, "content_html": t.contentHTML}).Error; err != nil {
return err
}
} else {
for _, c := range changes {
if err := translate.SetTranslated(ctx, tx, t.post, c.Attribute, t.locale, c.New); err != nil {
return err
}
}
}
if _, seen := byPost[t.post.ID]; !seen {
order = append(order, t.post)
}
byPost[t.post.ID] = append(byPost[t.post.ID], changes...)
}
locale, _ := towel.Locale(ctx)
purger := cooler.SiteCacheFromApp(app)
for _, post := range order {
cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
Locale: locale,
Changes: byPost[post.ID],
})
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
}
return nil
}

View File

@@ -525,3 +525,93 @@ func TestJournalSettingsTools(t *testing.T) {
t.Fatalf("applied post content_html = %q", got)
}
}
// TestSetImageAlt: POST /_journal/api/v1/images/alt sets alt on the
// matching img of a post's content_html (default or translated), answers
// the per-change diff, writes nothing on a dry run, refuses the whole batch
// with 422 when one src is not in the post, escapes the alt through the
// tokenizer (T-33-04) and needs golem15.journal.access_posts (D-85, P33-05).
func TestSetImageAlt(t *testing.T) {
env := newPostsEnv(t)
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
manager := env.admin(t, controllers.PermissionManageSettings)
const a, b = "https://cdn.example.test/a.jpg", "https://cdn.example.test/b.jpg"
body := `<p><img src="` + a + `"></p><p><img src="` + b + `" alt="old"></p>`
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
"title": "Images", "content": body,
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content": `<p><img src="` + a + `"></p>`}},
}))
path := journalAPIPrefix + "/images/alt"
const tricky = `Lion's "mane" <b>`
env.expect(t, http.StatusUnauthorized, http.MethodPost, path, "", map[string]any{"changes": []any{}})
env.expect(t, http.StatusForbidden, http.MethodPost, path, manager, map[string]any{"changes": []any{}})
decode := func(rec *httptest.ResponseRecorder) ([]map[string]any, any) {
var out struct {
Changes []map[string]any `json:"changes"`
DryRun any `json:"dry_run"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
}
return out.Changes, out.DryRun
}
changes, dry := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
"dry_run": true,
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": tricky}},
}))
if dry != true || len(changes) != 1 || changes[0]["old_alt"] != nil || changes[0]["alt"] != tricky || changes[0]["changed"] != true {
t.Fatalf("dry run answer = %#v, %v", changes, dry)
}
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "alt=\"Lion") {
t.Fatalf("dry run wrote: %q", got)
}
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
"changes": []any{
map[string]any{"post_id": post, "src": a, "alt": "kept out"},
map[string]any{"post_id": post, "src": "https://cdn.example.test/missing.jpg", "alt": "x"},
},
})
if !strings.Contains(rec.Body.String(), "changes.1.src") {
t.Fatalf("missing src answer %s", rec.Body.String())
}
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "kept out") {
t.Fatalf("refused batch wrote: %q", got)
}
records := listenRecords(t, env)
changes, dry = decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
"changes": []any{
map[string]any{"post_id": post, "src": a, "alt": tricky},
map[string]any{"post_id": post, "src": b, "alt": ""},
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
},
}))
if dry != false || len(changes) != 3 || changes[1]["old_alt"] != "old" || changes[2]["locale"] != "pl" {
t.Fatalf("applied answer = %#v, %v", changes, dry)
}
p := storedPost(t, env, post)
for _, html := range []string{p.ContentHTML, p.Content} {
if !strings.Contains(html, `alt="Lion&#39;s &#34;mane&#34; &lt;b&gt;"`) || strings.Contains(html, "<b>") || !strings.Contains(html, `alt=""`) {
t.Fatalf("applied default = %q", html)
}
}
pl := translatedAttrs(t, env, post, "pl")
if !strings.Contains(pl["content_html"].(string), `alt="Lew"`) {
t.Fatalf("applied pl = %#v", pl)
}
found := false
for _, ev := range records.savedEvents() {
if ev.ev.Model == p.MorphName() {
_, def := findChange(ev.ev.Changes, "content_html", "")
_, tr := findChange(ev.ev.Changes, "content_html", "pl")
found = def && tr
}
}
if !found {
t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents())
}
}

93
mcp.go
View File

@@ -69,12 +69,58 @@ 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 routes enforce their own permissions
// (publishing, other authors' posts) on every call. Media and featured-image
// uploads (multipart) are not tools.
// 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{
@@ -150,6 +196,47 @@ func (p *Plugin) MCP() pact.MCP {
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",

View File

@@ -17,8 +17,10 @@ import (
)
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
// exactly the seven post, category and tag tools and the draft_post prompt,
// each with Guard backend and golem15.journal.access_posts, delete marked
// exactly the seven post, category and tag tools, the four settings and
// content-fix tools (33-02) and the draft_post prompt, each with Guard
// backend and golem15.journal.access_posts (manage_settings for the
// settings and fix_headings tools), delete and set_image_alt marked
// destructive, and every tool bound to a route the plugin registers (the
// same method and pattern in a router built from the booted plugins).
// Runs under -short (no database).
@@ -26,17 +28,25 @@ func TestJournalMCPSmoke(t *testing.T) {
var _ pact.HasMCP = (*journal.Plugin)(nil)
decl := (&journal.Plugin{}).MCP()
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post",
"get_settings", "update_settings", "fix_headings", "set_image_alt"}
// The settings and heading tools need manage_settings (33-02, D-10).
manageSettings := map[string]bool{"get_settings": true, "update_settings": true, "fix_headings": true}
destructive := map[string]bool{"delete_post": true, "set_image_alt": true}
var names []string
for _, tool := range decl.Tools {
names = append(names, tool.Name)
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
t.Errorf("tool %s guard %q permissions %v, want backend and access_posts", tool.Name, tool.Guard, tool.Permissions)
want := []string{"golem15.journal.access_posts"}
if manageSettings[tool.Name] {
want = []string{"golem15.journal.manage_settings"}
}
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, want) {
t.Errorf("tool %s guard %q permissions %v, want backend and %v", tool.Name, tool.Guard, tool.Permissions, want)
}
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
t.Errorf("tool %s lacks a title, description or input", tool.Name)
}
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
if (tool.Destructive != nil && *tool.Destructive) != destructive[tool.Name] {
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
}
}

View File

@@ -11,6 +11,7 @@ import (
"testing"
journal "git.golem15.com/golem15/sm-journal-plugin"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
@@ -24,6 +25,13 @@ import (
var (
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
// maintenanceBodyKeys are the body keys of the settings and content
// fix routes, by path.
maintenanceBodyKeys = map[string][]string{
"/_journal/api/v1/settings": models.Settings{}.Fillable(),
"/_journal/api/v1/fix-headings": {"dry_run"},
"/_journal/api/v1/images/alt": {"dry_run", "changes"},
}
)
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
@@ -102,7 +110,7 @@ func TestJournalMCP(t *testing.T) {
if !ok {
t.Fatal("pier was not mounted")
}
want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"}
want := []string{"journal_create_post", "journal_delete_post", "journal_fix_headings", "journal_get_settings", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_set_image_alt", "journal_show_post", "journal_update_post", "journal_update_settings"}
if got := p.ToolNames(); !slices.Equal(got, want) {
t.Fatalf("compiled tools %v, want %v", got, want)
}
@@ -127,7 +135,11 @@ func TestJournalMCP(t *testing.T) {
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
}
default:
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
keys := postBodyKeys
if routeKeys, ok := maintenanceBodyKeys[tool.Path]; ok {
keys = routeKeys
}
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(keys, key) {
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
}
}

View File

@@ -34,6 +34,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Get("/settings", apicontrollers.ShowSettings(p.app))
g.Put("/settings", apicontrollers.UpdateSettings(p.app))
g.Post("/fix-headings", apicontrollers.FixHeadings(p.app))
g.Post("/images/alt", apicontrollers.SetImageAlt(p.app))
})
return nil
}