Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc07d8822 | |||
| 521df9a29d | |||
| 2aeeccc15f | |||
|
|
b1299021cd | ||
|
|
a9a9b8098d |
@@ -188,7 +188,7 @@ With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms
|
|||||||
| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change |
|
| `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) |
|
| `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.
|
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; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. 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
|
### Blog Editor
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SummerCMS v0.4.4 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests.
|
- SummerCMS v0.5.0 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests.
|
||||||
- Compiled plugin: `golem15.translate`.
|
- 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`.
|
- 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`.
|
- 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`.
|
||||||
@@ -400,7 +400,7 @@ 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.
|
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.
|
`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. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,7 +5,7 @@ go 1.27.0
|
|||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/sm-translate-plugin v0.1.0
|
git.golem15.com/golem15/sm-translate-plugin v0.1.0
|
||||||
git.golem15.com/golem15/sm-user-plugin v0.1.0
|
git.golem15.com/golem15/sm-user-plugin v0.1.0
|
||||||
git.golem15.com/golem15/summercms v0.4.4
|
git.golem15.com/golem15/summercms v0.5.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
|||||||
3
mcp.go
3
mcp.go
@@ -157,6 +157,9 @@ func (p *Plugin) MCP() pact.MCP {
|
|||||||
Description: "Drafts a Journal post on a topic and saves it as a draft.",
|
Description: "Drafts a Journal post on a topic and saves it as a draft.",
|
||||||
Guard: mcpGuard,
|
Guard: mcpGuard,
|
||||||
Permissions: []string{mcpAccessPosts},
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
// It directs journal_create_post, so a read-only grant does
|
||||||
|
// not see it.
|
||||||
|
Writes: true,
|
||||||
Arguments: []pact.MCPPromptArgument{
|
Arguments: []pact.MCPPromptArgument{
|
||||||
{Name: "topic", Description: "what the post is about", Required: true},
|
{Name: "topic", Description: "what the post is about", Required: true},
|
||||||
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
|
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
|
||||||
|
|||||||
194
mcp_prompt_writes_test.go
Normal file
194
mcp_prompt_writes_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// servingHost is mountHost that also keeps the endpoint pier mounts, so
|
||||||
|
// the test can talk to it.
|
||||||
|
type servingHost struct {
|
||||||
|
mountHost
|
||||||
|
path string
|
||||||
|
handlers map[string]http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
|
||||||
|
h.handlers[method] = handler
|
||||||
|
h.path = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if handler, ok := h.handlers[r.Method]; ok && r.URL.Path == h.path {
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptGrants verifies two OAuth grants of one linked user: read only and
|
||||||
|
// read+write, both with the backend scope and consented for the linked
|
||||||
|
// backend user 1 (pact.MCPGrant.BackendUserID).
|
||||||
|
type promptGrants struct{}
|
||||||
|
|
||||||
|
func (promptGrants) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
||||||
|
scopes := map[string][]string{"oas_at_reader": {"read", "backend"}, "oas_at_writer": {"read", "write", "backend"}}[raw]
|
||||||
|
if scopes == nil {
|
||||||
|
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
||||||
|
}
|
||||||
|
return pact.MCPGrant{UserID: 5, ClientID: "agent", TokenID: 9, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour), BackendUserID: 1}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (promptGrants) MCPResource() string { return "https://app.example.test/mcp" }
|
||||||
|
|
||||||
|
// linkedAdmin links frontend user 5 to backend user 1.
|
||||||
|
type linkedAdmin struct{}
|
||||||
|
|
||||||
|
func (linkedAdmin) LinkedBackendUser(context.Context, uint) (pact.LinkedBackendUser, bool, error) {
|
||||||
|
return pact.LinkedBackendUser{ID: 1, Login: "editor"}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowingBackend stands in for the backend guard provider (which needs the
|
||||||
|
// admin database): it allows the linked admin everything.
|
||||||
|
type allowingBackend struct{}
|
||||||
|
|
||||||
|
func (allowingBackend) MCPGuardName() string { return pier.BackendGuard }
|
||||||
|
func (allowingBackend) MCPTokenGuarded(mw []string) bool {
|
||||||
|
return slices.Contains(mw, "backend")
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
||||||
|
return pact.MCPRequirements{}, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPDispatch(_ context.Context, s pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||||
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{pier.BackendGuard}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptsFor opens a stateful session with bearer and answers the prompt
|
||||||
|
// names prompts/list returns.
|
||||||
|
func promptsFor(t *testing.T, url, bearer string) []string {
|
||||||
|
t.Helper()
|
||||||
|
post := func(session, body string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
|
||||||
|
if session != "" {
|
||||||
|
req.Header.Set("Mcp-Session-Id", session)
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
t.Fatalf("POST %s = %d %s", body, res.StatusCode, raw)
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(res.Body)
|
||||||
|
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if line := scanner.Text(); strings.HasPrefix(line, "data: ") {
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), strings.TrimPrefix(line, "data: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), ""
|
||||||
|
}
|
||||||
|
session, _ := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"journal-test","version":"1"}}}`)
|
||||||
|
if session == "" {
|
||||||
|
t.Fatal("initialize opened no session")
|
||||||
|
}
|
||||||
|
post(session, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||||
|
_, body := post(session, `{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}`)
|
||||||
|
var out struct {
|
||||||
|
Result struct {
|
||||||
|
Prompts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"prompts"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Fatalf("prompts/list = %s (%v)", body, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, p := range out.Result.Prompts {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalPromptWrites pins plan 08's prompt ceiling for the blog plugin
|
||||||
|
// (C-IN-01): draft_post declares Writes, and through pier's catalog (the
|
||||||
|
// TestJournalMCP harness, served) a read-only grant's prompts/list omits
|
||||||
|
// journal_draft_post while a read and write grant lists it. Runs under
|
||||||
|
// -short.
|
||||||
|
func TestJournalPromptWrites(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
t.Run("draft_post declares Writes", func(t *testing.T) {
|
||||||
|
i := slices.IndexFunc(decl.Prompts, func(p pact.MCPPrompt) bool { return p.Name == "draft_post" })
|
||||||
|
if i < 0 || !decl.Prompts[i].Writes {
|
||||||
|
t.Fatalf("draft_post = %+v, want Writes", decl.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prompts/list follows the read/write ceiling", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPTokenVerifier](promptGrants{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPLinkedBackendResolver](linkedAdmin{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
guards := pact.NewMCPGuards()
|
||||||
|
if err := guards.Register(allowingBackend{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish(guards); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := &servingHost{mountHost: mountHost{r: router}, handlers: map[string]http.Handler{}}
|
||||||
|
if err := pier.Mount(host, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(host)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
endpoint := srv.URL + host.path
|
||||||
|
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_reader"); slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read-only grant lists %v", got)
|
||||||
|
}
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_writer"); !slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read and write grant lists %v, want journal_draft_post", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
149
mcp_test.go
Normal file
149
mcp_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"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/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The JSON keys the post routes read (controllers/api/posts_helpers.go:
|
||||||
|
// body["..."] in the create and update validation, and the list query).
|
||||||
|
// A tool input field outside these would be sent and silently ignored.
|
||||||
|
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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||||
|
type mountHost struct{ r *surf.Router }
|
||||||
|
|
||||||
|
func (h mountHost) Routes() []pier.RouteInfo {
|
||||||
|
var out []pier.RouteInfo
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
|
||||||
|
return h.r.Handler(method, pattern)
|
||||||
|
}
|
||||||
|
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
||||||
|
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
|
||||||
|
func (h mountHost) Owner(method, path string) (string, bool) {
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
if ri.Method == method && ri.Pattern == path {
|
||||||
|
return ri.PluginID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalApp boots the translate and journal plugins on a config without a
|
||||||
|
// database.
|
||||||
|
func journalApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return app, plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the
|
||||||
|
// declaration compiles through pier's own catalog against the routes the
|
||||||
|
// plugin registers (prefix journal_, the backend guard, path and query
|
||||||
|
// tags matching the patterns); every input field is a key the route reads
|
||||||
|
// (path segment, list query key or create/update body key); the draft
|
||||||
|
// prompt renders with and without its optional tone. The dispatch marker
|
||||||
|
// on the optional-principal list and show routes is pinned by
|
||||||
|
// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short.
|
||||||
|
func TestJournalMCP(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
|
t.Run("compiles through pier against the plugin's routes", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
p, ok := app.Lookup[*pier.Pier]()
|
||||||
|
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"}
|
||||||
|
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("input fields are keys the routes read", func(t *testing.T) {
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
rt := reflect.TypeOf(tool.Input)
|
||||||
|
for rt.Kind() == reflect.Pointer {
|
||||||
|
rt = rt.Elem()
|
||||||
|
}
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
f := rt.Field(i)
|
||||||
|
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||||
|
switch {
|
||||||
|
case f.Tag.Get("path") != "":
|
||||||
|
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
|
||||||
|
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
|
||||||
|
}
|
||||||
|
case f.Tag.Get("query") != "":
|
||||||
|
if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) {
|
||||||
|
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) {
|
||||||
|
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("draft prompt with and without tone", func(t *testing.T) {
|
||||||
|
prompt := decl.Prompts[0]
|
||||||
|
with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"})
|
||||||
|
if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") {
|
||||||
|
t.Fatalf("with tone = %+v, %v", with, err)
|
||||||
|
}
|
||||||
|
without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"})
|
||||||
|
if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") {
|
||||||
|
t.Fatalf("without tone = %+v, %v", without, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user