diff --git a/README.md b/README.md index 0d1d5d2..4dea210 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types). - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. +- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools). - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. @@ -159,7 +160,7 @@ Content-Type: application/json {"title":"Agent draft","content":"Written with a journal token"} ``` -Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token. A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`. +Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token, or when the MCP endpoint dispatches the request in-process as a backend principal (no `Authorization` header then; `cabana.ResolveBackend` reads the dispatch marker). A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`. This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal). @@ -173,6 +174,22 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} } func (Post) MorphName() string { return `Golem15\Journal\Models\Post` } ``` +### MCP tools + +With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled: true`), an MCP client connected through the application's OAuth server with the `backend` scope and a verified link to an administrator sees these tools when that administrator has `golem15.journal.access_posts`: + +| Tool | Route | Input | +|------|-------|-------| +| `journal_list_posts` | `GET /_journal/api/v1/posts` | query `page`, `per_page`, `search`, `category`, `tag`, `author`, `published`, `sort` | +| `journal_show_post` | `GET /_journal/api/v1/posts/{slug}` | `slug` | +| `journal_list_categories` | `GET /_journal/api/v1/categories` | none | +| `journal_list_tags` | `GET /_journal/api/v1/tags` | none | +| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) | +| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change | +| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) | + +The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft. A grant without the `write` scope lists only the read tools. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools. + ### Blog Editor `assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`). @@ -302,6 +319,7 @@ The request is a `POST` to the URL with `Authorization: Bearer `, `Conten | `journal.Plugin.Models` | Models registered by this plugin. | | `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. | | `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. | +| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. | | `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). | | `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. | | `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). | @@ -365,7 +383,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex ## Dependencies -- SummerCMS v0.4.0 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests. +- SummerCMS v0.4.4 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests. - Compiled plugin: `golem15.translate`. - Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`. - Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`. @@ -382,6 +400,8 @@ Postgres integration smokes start Docker unless `-short`. Missing Docker is a fa Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths. +`TestJournalMCPSmoke` (root package, Docker-free) pins the seven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. + `TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule. The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit. diff --git a/controllers/api/auth.go b/controllers/api/auth.go index da11cfc..599de89 100644 --- a/controllers/api/auth.go +++ b/controllers/api/auth.go @@ -63,11 +63,14 @@ func resolveError(w http.ResponseWriter, err error) { writeAuthRequired(w, err) } -// optionalBackendPrincipal verifies a backend JWT or personal API token only -// when Authorization Bearer is present. Failure is anonymous: public GET must -// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*. +// optionalBackendPrincipal verifies a backend JWT or personal API token when +// one is presented, and honours the in-process dispatch marker the MCP +// endpoint sets (no Authorization header then, D-17). Failure is anonymous: +// public GET must not 401 (ResolveBackend answers ErrUnauthenticated for a +// request without a bearer). Uses cabana.ResolveBackend with cover +// golem15.journal.*. func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request { - if r == nil || !hasBearer(r) { + if r == nil { return r } out, _, err := cabana.ResolveBackend(app, r, journalCover) diff --git a/controllers/api/optional_principal_test.go b/controllers/api/optional_principal_test.go new file mode 100644 index 0000000..f211865 --- /dev/null +++ b/controllers/api/optional_principal_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// TestOptionalBackendPrincipalDispatch pins the D-17 fix: a request the MCP +// endpoint dispatches in-process carries no Authorization header but the +// backend dispatch marker, and the list and show handlers must see its +// principal; a request with neither stays anonymous. +func TestOptionalBackendPrincipalDispatch(t *testing.T) { + app := backpack.New(nil) + admin := &bouncer.Principal{ID: 42, Backend: true} + + req := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil) + req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: admin})) + out := optionalBackendPrincipal(app, req) + if pr, ok := bouncer.User(out.Context()); !ok || pr != admin { + t.Fatalf("dispatched request principal = %+v (%v), want the marker's admin", pr, ok) + } + + anon := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil) + if out := optionalBackendPrincipal(app, anon); out != anon { + t.Fatal("a request without a bearer or marker was changed") + } + if _, ok := bouncer.User(anon.Context()); ok { + t.Fatal("an anonymous request gained a principal") + } + + // A marker for another guard is not the backend's. + other := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil) + other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 7}})) + if out := optionalBackendPrincipal(app, other); out != other { + t.Fatal("a marker of another guard yielded a backend principal") + } +} diff --git a/go.mod b/go.mod index 21e8923..a038030 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.0 require ( git.golem15.com/golem15/sm-translate-plugin v0.1.0 git.golem15.com/golem15/sm-user-plugin v0.1.0 - git.golem15.com/golem15/summercms v0.4.0 + git.golem15.com/golem15/summercms v0.4.4 github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/goccy/go-yaml v1.19.2 github.com/jackc/pgx/v5 v5.10.0 @@ -85,6 +85,7 @@ require ( github.com/go-playground/validator/v10 v10.30.4 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect @@ -121,6 +122,7 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect @@ -138,6 +140,8 @@ require ( github.com/riverqueue/river/rivershared v0.47.0 // indirect github.com/riverqueue/river/rivertype v0.47.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -151,6 +155,7 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/wneessen/go-mail v0.8.1 // indirect github.com/xo/terminfo v1.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect @@ -163,10 +168,12 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/image v0.46.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.272.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/go.sum b/go.sum index f7ff7a6..6ec4f77 100644 --- a/go.sum +++ b/go.sum @@ -207,6 +207,8 @@ github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -297,6 +299,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -337,6 +341,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -377,6 +385,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= @@ -440,6 +450,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..02d91db --- /dev/null +++ b/mcp.go @@ -0,0 +1,185 @@ +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 +} diff --git a/mcp_smoke_test.go b/mcp_smoke_test.go new file mode 100644 index 0000000..308e987 --- /dev/null +++ b/mcp_smoke_test.go @@ -0,0 +1,93 @@ +package journal_test + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + journal "git.golem15.com/golem15/sm-journal-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// 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 +// 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). +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"} + 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) + } + 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") { + t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive) + } + } + if !slices.Equal(names, wantTools) { + t.Fatalf("tools %v, want %v", names, wantTools) + } + if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" || + !slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) { + t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts) + } + prompt := decl.Prompts[0] + if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required { + t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments) + } + msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"}) + if err != nil || len(msgs) != 1 || msgs[0].Role != "user" || + !strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") || + !strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") { + t.Fatalf("draft_post render = %+v, %v", msgs, err) + } + + // Every tool's method and path is a route the plugin registers. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}}) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"}) + if err != nil { + t.Fatal(err) + } + rt, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatalf("surf.BuildRouter: %v", err) + } + routes := map[string]string{} + for _, r := range rt.Routes() { + routes[r.Method+" "+r.Pattern] = r.PluginID + } + for _, tool := range decl.Tools { + if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" { + t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner) + } + } +}