From af25eaa677a0b28d4a85af1175cd399655273494 Mon Sep 17 00:00:00 2001
From: Jakub Zych
Date: Wed, 4 Nov 2026 10:10:52 +0100
Subject: [PATCH] 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
---
classes/image_alt.go | 92 ++++++++++++
classes/image_alt_test.go | 22 +++
console/fix_headings.go | 14 +-
console/import_posts.go | 2 +-
controllers/api/maintenance.go | 260 +++++++++++++++++++++++++++++++++
headings_test.go | 90 ++++++++++++
mcp.go | 93 +++++++++++-
mcp_smoke_test.go | 22 ++-
mcp_test.go | 16 +-
routes.go | 1 +
10 files changed, 597 insertions(+), 15 deletions(-)
create mode 100644 classes/image_alt.go
create mode 100644 classes/image_alt_test.go
diff --git a/classes/image_alt.go b/classes/image_alt.go
new file mode 100644
index 0000000..b034f4e
--- /dev/null
+++ b/classes/image_alt.go
@@ -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, "
x & y

`
+ out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" `)
+ want := `x & y

`
+ 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 != `x & y

` || 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)
+ }
+}
diff --git a/console/fix_headings.go b/console/fix_headings.go
index 943a295..e159878 100644
--- a/console/fix_headings.go
+++ b/console/fix_headings.go
@@ -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},
diff --git a/console/import_posts.go b/console/import_posts.go
index bc26ee9..9d206d6 100644
--- a/console/import_posts.go
+++ b/console/import_posts.go
@@ -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"
)
diff --git a/controllers/api/maintenance.go b/controllers/api/maintenance.go
index 147377f..5b6d08b 100644
--- a/controllers/api/maintenance.go
+++ b/controllers/api/maintenance.go
@@ -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
+}
diff --git a/headings_test.go b/headings_test.go
index ce8e3e7..73a44e8 100644
--- a/headings_test.go
+++ b/headings_test.go
@@ -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 := `

`
+ 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": `
`}},
+ }))
+ path := journalAPIPrefix + "/images/alt"
+ const tricky = `Lion's "mane" `
+
+ 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's "mane" <b>"`) || strings.Contains(html, "") || !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())
+ }
+}
diff --git a/mcp.go b/mcp.go
index a205445..4abf7b9 100644
--- a/mcp.go
+++ b/mcp.go
@@ -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",
diff --git a/mcp_smoke_test.go b/mcp_smoke_test.go
index 308e987..cc0f2d8 100644
--- a/mcp_smoke_test.go
+++ b/mcp_smoke_test.go
@@ -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)
}
}
diff --git a/mcp_test.go b/mcp_test.go
index a60fcca..8d8e6bc 100644
--- a/mcp_test.go
+++ b/mcp_test.go
@@ -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)
}
}
diff --git a/routes.go b/routes.go
index 4b35260..6cb2408 100644
--- a/routes.go
+++ b/routes.go
@@ -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
}