feat: MCP tools for posts, categories and tags, the draft_post prompt

- journal.Plugin.MCP: list_posts, show_post, list_categories, list_tags,
  create_post, update_post, delete_post and the draft_post prompt, guard
  backend, listed with golem15.journal.access_posts; update fields are
  optional so only sent keys reach the route
- optionalBackendPrincipal honours the MCP dispatch marker (no bearer
  short-circuit; anonymous reads unchanged)
- require summercms v0.4.4
- TestJournalMCPSmoke, TestOptionalBackendPrincipalDispatch
This commit is contained in:
Jakub Zych
2026-10-23 12:54:03 +02:00
parent ffa2c8ffb1
commit 4ab94da66c
7 changed files with 368 additions and 7 deletions

View File

@@ -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)

View File

@@ -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")
}
}