feat(19.7-02): identity kinds, ceilings, prompts and the MCP services page
- personal tokens only behind mcp.allow_personal_tokens (401 without a lookup otherwise); an admin token yields a backend-only identity with its scope limit per dispatched method, a user token a frontend-only one - read/write ceiling and prompts proven by the tracer's subtests; the audit line asserted to carry the tool and never the arguments or token - the mounted *pier.Pier is published on the application (test seams in export_test.go) - docs/services/mcp.md with the tested acme.blog example, site.yaml note, cross-links from authentication.md, registration.md and the surf README
This commit is contained in:
@@ -141,4 +141,4 @@ Only this module (and surf's tagged `pier_on.go`) imports the SDK; `pact` stays
|
||||
|
||||
## Testing
|
||||
|
||||
`go test -short ./modules/pier/...` runs without Docker, a real Redis or a build tag: `TestPierTracerSmoke` builds a real surf router with a fake plugin, mounts pier through an in-test `pier.Host` adapter, connects the SDK client over `httptest` and proves `tools/list`, `tools/call` through the compiled guard chain with the dispatch marker (no `Authorization` or `Cookie` reaches the route), the 401 challenge, and the boot errors for a duplicate name and an unknown route; `TestPierSessionsSmoke` runs two instances on one miniredis (rehydration, the binding refusal, DELETE, the idle TTL, the per-binding cap); `TestPierFanOutSmoke` receives `list_changed` on one instance for an event fired on the other and sees a revocation end the session; `TestPierStatelessSmoke` connects the newest SDK client statelessly next to a 2025-11-25 client with a session. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.
|
||||
`go test -short ./modules/pier/...` runs without Docker, a real Redis or a build tag: `TestPierTracerSmoke` builds a real surf router with a fake plugin, mounts pier through an in-test `pier.Host` adapter, connects the SDK client over `httptest` and proves `tools/list`, `tools/call` through the compiled guard chain with the dispatch marker (no `Authorization` or `Cookie` reaches the route), the 401 challenge, and the boot errors for a duplicate name and an unknown route; `TestPierSessionsSmoke` runs two instances on one miniredis (rehydration, the binding refusal, DELETE, the idle TTL, the per-binding cap); `TestPierFanOutSmoke` receives `list_changed` on one instance for an event fired on the other and sees a revocation end the session; `TestPierStatelessSmoke` connects the newest SDK client statelessly next to a 2025-11-25 client with a session. The tracer's subtests cover the read/write ceiling (a read-only grant lists the GET tool only), prompts (`prompts/list`, `prompts/get`, the required-argument refusal), the audit line (tool, user and status present, arguments and token absent) and personal tokens (401 without the flag and no lookup; with the flag an injected admin token resolver yields a backend-only identity bound to `pat:backend:<id>`). `TestExampleBlogPlugin` keeps the `acme.blog` example of `docs/services/mcp.md` mounting. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.
|
||||
|
||||
174
modules/pier/example_mcp_test.go
Normal file
174
modules/pier/example_mcp_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package pier_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// The neutral acme.blog example docs/services/mcp.md shows: a plugin that
|
||||
// declares two tools and one prompt bound to its own token-guarded routes.
|
||||
// TestExampleBlogPlugin keeps it compiling and mounting.
|
||||
|
||||
// blogTools is the MCP declaration of the acme.blog plugin. A field of an
|
||||
// input struct is a body field unless its path or query tag says
|
||||
// otherwise; fields without omitempty are required in the schema.
|
||||
func blogTools() pact.MCP {
|
||||
type createPostInput struct {
|
||||
Title string `json:"title" jsonschema:"the post title"`
|
||||
Body string `json:"body" jsonschema:"the Markdown body"`
|
||||
Published bool `json:"published,omitempty" jsonschema:"publish at once instead of saving a draft"`
|
||||
}
|
||||
type showPostInput struct {
|
||||
ID int `json:"id" path:"id" jsonschema:"the post id"`
|
||||
}
|
||||
type listPostsInput struct {
|
||||
Query string `json:"q,omitempty" query:"q" jsonschema:"a phrase the title or body contains"`
|
||||
}
|
||||
return pact.MCP{
|
||||
Tools: []pact.MCPTool{
|
||||
{
|
||||
// Exposed as blog_create_post; the backend guard runs it as
|
||||
// the linked administrator, listed only with access_posts.
|
||||
Name: "create_post",
|
||||
Title: "Create post",
|
||||
Description: "Creates a post; a draft unless published is true.",
|
||||
Guard: "backend",
|
||||
Method: "POST",
|
||||
Path: "/_blog/api/v1/posts",
|
||||
Input: createPostInput{},
|
||||
Permissions: []string{"acme.blog.access_posts"},
|
||||
},
|
||||
{
|
||||
// Exposed as blog_show_post; the route's user.api_token
|
||||
// middleware names the guard, GET makes it read-only.
|
||||
Name: "show_post",
|
||||
Title: "Show post",
|
||||
Description: "Shows one post by id.",
|
||||
Method: "GET",
|
||||
Path: "/_blog/api/v1/posts/{id}",
|
||||
Input: showPostInput{},
|
||||
},
|
||||
{
|
||||
Name: "list_posts",
|
||||
Title: "List posts",
|
||||
Description: "Lists posts, optionally those matching a phrase.",
|
||||
Method: "GET",
|
||||
Path: "/_blog/api/v1/posts",
|
||||
Input: listPostsInput{},
|
||||
},
|
||||
},
|
||||
Prompts: []pact.MCPPrompt{
|
||||
{
|
||||
// Exposed as blog_summarize; prompts name their guard.
|
||||
Name: "summarize",
|
||||
Title: "Summarize a post",
|
||||
Description: "Asks for a three-sentence summary of a post.",
|
||||
Guard: "user",
|
||||
Arguments: []pact.MCPPromptArgument{{Name: "slug", Description: "the post slug", Required: true}},
|
||||
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
||||
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize the post " + args["slug"] + " in three sentences."}}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// userTokenGuard stands in for a user plugin's API token guard: it refuses
|
||||
// every HTTP request, so only an in-process dispatch reaches the routes.
|
||||
type userTokenGuard struct{}
|
||||
|
||||
func (userTokenGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
|
||||
return nil, nil, errors.New("blog: no token")
|
||||
}
|
||||
|
||||
func (userTokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
|
||||
http.Error(w, `{"error":"Invalid token"}`, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// userGuardProvider is the "user" guard provider such a plugin publishes.
|
||||
type userGuardProvider struct{}
|
||||
|
||||
func (userGuardProvider) MCPGuardName() string { return "user" }
|
||||
func (userGuardProvider) MCPTokenGuarded(mw []string) bool {
|
||||
return slices.Contains(mw, "user.api_token")
|
||||
}
|
||||
func (userGuardProvider) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
||||
return pact.MCPRequirements{}, nil
|
||||
}
|
||||
func (userGuardProvider) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (userGuardProvider) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"api_token"}}, nil
|
||||
}
|
||||
|
||||
// blogPlugin is the acme.blog plugin of the example.
|
||||
type blogPlugin struct{}
|
||||
|
||||
func (blogPlugin) ID() string { return "acme.blog" }
|
||||
func (blogPlugin) Requires() []string { return nil }
|
||||
func (blogPlugin) Register(*backpack.App) error { return nil }
|
||||
func (blogPlugin) MCP() pact.MCP { return blogTools() }
|
||||
|
||||
func (blogPlugin) Boot(app *backpack.App) error {
|
||||
reg := pact.NewMCPGuards()
|
||||
if err := reg.Register(userGuardProvider{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Publish(reg)
|
||||
}
|
||||
|
||||
func (p blogPlugin) Middlewares() map[string]pact.Middleware {
|
||||
guards := bouncer.NewRegistry()
|
||||
if err := guards.Register(p.ID(), "api_token", userTokenGuard{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
auth, err := guards.Middleware("api_token")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return map[string]pact.Middleware{"user.api_token": auth, "backend": auth}
|
||||
}
|
||||
|
||||
func (blogPlugin) Routes(r pact.Router) error {
|
||||
ok := func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}
|
||||
r.Group("/_blog/api/v1", []string{"user.api_token"}, func(g pact.Router) {
|
||||
g.Get("/posts", ok)
|
||||
g.Get("/posts/{id}", ok)
|
||||
})
|
||||
r.Group("/_blog/api/v1", []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/posts", ok)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExampleBlogPlugin(t *testing.T) {
|
||||
if decl := blogTools(); len(decl.Tools) != 3 || len(decl.Prompts) != 1 {
|
||||
t.Fatalf("declaration = %d tools, %d prompts", len(decl.Tools), len(decl.Prompts))
|
||||
}
|
||||
app := pierAppWith(t, map[string]string{"mcp.yaml": "enabled: false\n"}, nil)
|
||||
plugin := blogPlugin{}
|
||||
if err := plugin.Boot(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, err := mountPier(t, app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := host.pier.ToolNames()
|
||||
want := []string{"blog_create_post", "blog_list_posts", "blog_show_post"}
|
||||
if !slices.Equal(names, want) {
|
||||
t.Fatalf("tool names = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
25
modules/pier/export_test.go
Normal file
25
modules/pier/export_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package pier
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// Test seams (package pier, compiled into the test binary only).
|
||||
|
||||
// SetBackendTokens replaces the admin personal token resolver, which needs
|
||||
// a database in production (cabana.ResolveBackendToken).
|
||||
func (p *Pier) SetBackendTokens(fn func(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error)) {
|
||||
p.identities.backendTokens = fn
|
||||
}
|
||||
|
||||
// Resolve resolves a bearer into an Identity as the endpoint does.
|
||||
func (p *Pier) Resolve(ctx context.Context, raw string) (Identity, error) {
|
||||
return p.identities.resolve(ctx, raw)
|
||||
}
|
||||
|
||||
// Ceiling reports whether the read/write ceiling applies to the identity.
|
||||
func (id Identity) Ceiling() bool { return id.ceiling }
|
||||
@@ -135,8 +135,10 @@ func newIdentityResolver(app *backpack.App, cfg Config, frontendGuard string) *i
|
||||
|
||||
// resolve verifies raw afresh: an OAuth access token through the published
|
||||
// pact.MCPTokenVerifier (missing verifier: unauthorized); with
|
||||
// mcp.allow_personal_tokens, an admin g15_ token through cabana and any
|
||||
// other prefix through the published pact.MCPPersonalTokenResolver. A
|
||||
// mcp.allow_personal_tokens true, an admin g15_ token through cabana and
|
||||
// any other prefix through the published pact.MCPPersonalTokenResolver;
|
||||
// with mcp.allow_personal_tokens false (the default) any bearer that is
|
||||
// not an OAuth token is unauthorized without a lookup (D-19). A
|
||||
// backend subject for an OAuth grant exists only with the backend scope and
|
||||
// a link the published pact.MCPLinkedBackendResolver confirms (D-08).
|
||||
func (r *identityResolver) resolve(ctx context.Context, raw string) (Identity, error) {
|
||||
|
||||
200
modules/pier/identity_smoke_test.go
Normal file
200
modules/pier/identity_smoke_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package pier_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// Task 4 cases of the tracer: identity kinds behind the flag, the
|
||||
// read/write ceiling, prompts and the audit line.
|
||||
|
||||
type showInput struct {
|
||||
ID int `json:"id" path:"id" jsonschema:"the item id"`
|
||||
}
|
||||
|
||||
func showTool() pact.MCPTool {
|
||||
return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}
|
||||
}
|
||||
|
||||
func summarizePrompt() pact.MCPPrompt {
|
||||
return pact.MCPPrompt{
|
||||
Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme",
|
||||
Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}},
|
||||
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
||||
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession {
|
||||
t.Helper()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
|
||||
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}}
|
||||
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
||||
if err != nil {
|
||||
t.Fatalf("connect with %s: %v", token, err)
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func toolNamesOf(list *mcp.ListToolsResult) []string {
|
||||
var out []string
|
||||
for _, tl := range list.Tools {
|
||||
out = append(out, tl.Name)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func tracerIdentityCases(t *testing.T, ctx context.Context) {
|
||||
plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}, prompts: []pact.MCPPrompt{summarizePrompt()}}}
|
||||
|
||||
t.Run("read-write ceiling and prompts", func(t *testing.T) {
|
||||
app := pierApp(t, "enabled: false\n")
|
||||
host, err := mountPier(t, app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(host)
|
||||
defer srv.Close()
|
||||
|
||||
readOnly := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_read")
|
||||
defer readOnly.Close()
|
||||
list, err := readOnly.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_show"}) {
|
||||
t.Fatalf("read-only grant lists %v, want [acme_show]", got)
|
||||
}
|
||||
if res, err := readOnly.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 1, "text": "x"}}); err == nil {
|
||||
t.Fatalf("read-only grant called a write tool: %+v", res)
|
||||
}
|
||||
writer := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_test")
|
||||
defer writer.Close()
|
||||
list, err = writer.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
|
||||
t.Fatalf("write grant lists %v, want [acme_echo acme_show]", got)
|
||||
}
|
||||
show, err := writer.CallTool(ctx, &mcp.CallToolParams{Name: "acme_show", Arguments: map[string]any{"id": 12}})
|
||||
if err != nil || show.IsError {
|
||||
t.Fatalf("acme_show = %+v, %v", show, err)
|
||||
}
|
||||
if structured, _ := show.StructuredContent.(map[string]any); structured["item"] != "12" {
|
||||
t.Fatalf("acme_show structuredContent = %+v", show.StructuredContent)
|
||||
}
|
||||
|
||||
prompts, err := writer.ListPrompts(ctx, nil)
|
||||
if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "acme_summarize" {
|
||||
t.Fatalf("prompts/list = %+v, %v", prompts, err)
|
||||
}
|
||||
got, err := writer.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_summarize", Arguments: map[string]string{"topic": "the pier"}})
|
||||
if err != nil || len(got.Messages) != 1 {
|
||||
t.Fatalf("prompts/get = %+v, %v", got, err)
|
||||
}
|
||||
if text, ok := got.Messages[0].Content.(*mcp.TextContent); !ok || text.Text != "Summarize the pier." || got.Messages[0].Role != "user" {
|
||||
t.Fatalf("prompt message = %+v", got.Messages[0])
|
||||
}
|
||||
if _, err := writer.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_summarize"}); err == nil || !strings.Contains(err.Error(), "topic") {
|
||||
t.Fatalf("prompts/get without the required argument: err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audit line", func(t *testing.T) {
|
||||
app := pierApp(t, "enabled: false\n")
|
||||
host, err := mountPier(t, app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(host)
|
||||
defer srv.Close()
|
||||
var buf bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
defer slog.SetDefault(previous)
|
||||
cs := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_test")
|
||||
defer cs.Close()
|
||||
if _, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 4, "text": "secret-argument-text"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "pier: tool call") || !strings.Contains(logged, "tool=acme_echo") || !strings.Contains(logged, "user_id=7") || !strings.Contains(logged, "status=200") {
|
||||
t.Fatalf("audit line missing: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "secret-argument-text") || strings.Contains(logged, "oas_at_test") {
|
||||
t.Fatalf("audit line leaks arguments or the token: %s", logged)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("personal tokens behind the flag", func(t *testing.T) {
|
||||
called := false
|
||||
resolver := func(_ context.Context, _ *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error) {
|
||||
called = true
|
||||
if raw != "g15_admin" {
|
||||
return nil, nil, nil, cabana.ErrUnauthenticated
|
||||
}
|
||||
return &bouncer.Principal{ID: 42, Backend: true}, &cabana.BackendAPIToken{ID: 5, BackendUserID: 42}, []string{"admin:read"}, nil
|
||||
}
|
||||
off := pierApp(t, "enabled: false\n")
|
||||
offHost, err := mountPier(t, off, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offHost.pier.SetBackendTokens(resolver)
|
||||
offSrv := httptest.NewServer(offHost)
|
||||
defer offSrv.Close()
|
||||
if reply := rpc(t, ctx, offSrv.URL+"/mcp", "g15_admin", "", initializeBody); reply.status != http.StatusUnauthorized {
|
||||
t.Fatalf("g15_ bearer with the flag off = %d %s", reply.status, reply.body)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("the flag off still looked the personal token up")
|
||||
}
|
||||
if _, err := offHost.pier.Resolve(ctx, "usr_token"); err == nil {
|
||||
t.Fatal("a frontend personal token resolved with the flag off")
|
||||
}
|
||||
|
||||
on := pierApp(t, "enabled: false\nallow_personal_tokens: true\n")
|
||||
onHost, err := mountPier(t, on, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onHost.pier.SetBackendTokens(resolver)
|
||||
id, err := onHost.pier.Resolve(ctx, "g15_admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id.Backend == nil || id.Backend.Guard != "backend" || id.Backend.UserID != 42 || id.Backend.TokenID != 5 || id.Frontend != nil || id.Binding != "pat:backend:5" || id.TokenKind != "pat" || id.Ceiling() {
|
||||
t.Fatalf("g15_ identity = %+v", id)
|
||||
}
|
||||
if _, err := onHost.pier.Resolve(ctx, "g15_wrong"); err == nil {
|
||||
t.Fatal("an unknown g15_ token resolved")
|
||||
}
|
||||
onSrv := httptest.NewServer(onHost)
|
||||
defer onSrv.Close()
|
||||
cs := connectPinned(t, ctx, onSrv.URL+"/mcp", "g15_admin")
|
||||
defer cs.Close()
|
||||
list, err := cs.ListTools(ctx, nil)
|
||||
if err != nil || len(list.Tools) != 0 {
|
||||
t.Fatalf("an admin token lists %+v, want no frontend tools (%v)", list, err)
|
||||
}
|
||||
if _, err := onHost.pier.Resolve(ctx, "usr_token"); err == nil {
|
||||
t.Fatal("a frontend personal token resolved without a published resolver")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -66,7 +66,8 @@ type Pier struct {
|
||||
// behind the inline throttle. Nothing is mounted when the flag is off. A
|
||||
// bad binding, a duplicate name, an unknown guard or a route already
|
||||
// registered at mcp.path fails with an error naming the plugin, so boot
|
||||
// (and route:list) fail. Nothing dials Redis here.
|
||||
// (and route:list) fail. Nothing dials Redis here. The mounted *Pier is
|
||||
// published on the application.
|
||||
func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
|
||||
if host == nil || app == nil {
|
||||
return errors.New("pier: a host and an application are required")
|
||||
@@ -126,6 +127,9 @@ func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
|
||||
return err
|
||||
}
|
||||
p.listenEvents()
|
||||
if err := app.Publish(p); err != nil {
|
||||
return fmt.Errorf("pier: publishing the endpoint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ type echoInput struct {
|
||||
// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one
|
||||
// guarded route and the MCP tools it is given.
|
||||
type acmePlugin struct {
|
||||
tools []pact.MCPTool
|
||||
tools []pact.MCPTool
|
||||
prompts []pact.MCPPrompt
|
||||
}
|
||||
|
||||
func (acmePlugin) ID() string { return "test.acme" }
|
||||
@@ -83,6 +84,10 @@ func (p acmePlugin) Middlewares() map[string]pact.Middleware {
|
||||
|
||||
func (acmePlugin) Routes(r pact.Router) error {
|
||||
r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) {
|
||||
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`))
|
||||
})
|
||||
g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) {
|
||||
user, _ := bouncer.User(req.Context())
|
||||
var body map[string]any
|
||||
@@ -103,7 +108,7 @@ func (acmePlugin) Routes(r pact.Router) error {
|
||||
}
|
||||
|
||||
func (p acmePlugin) MCP() pact.MCP {
|
||||
return pact.MCP{Tools: p.tools}
|
||||
return pact.MCP{Tools: p.tools, Prompts: p.prompts}
|
||||
}
|
||||
|
||||
// acmeGuard is the fake guard provider for "acme": token-guarded by
|
||||
@@ -142,6 +147,8 @@ func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant
|
||||
return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
case "oas_at_other":
|
||||
return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
case "oas_at_read":
|
||||
return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
||||
}
|
||||
@@ -154,6 +161,7 @@ type testHost struct {
|
||||
r *surf.Router
|
||||
handlers map[string]http.Handler
|
||||
path string
|
||||
pier *pier.Pier
|
||||
}
|
||||
|
||||
func (h *testHost) Routes() []pier.RouteInfo {
|
||||
@@ -212,9 +220,10 @@ func pierApp(t *testing.T, mcpYAML string) *backpack.App {
|
||||
}
|
||||
|
||||
// pierAppWith builds an app from extra config files (app.yaml and
|
||||
// http.yaml have defaults) and publishes the fake verifier and the given
|
||||
// guard provider; the router is built with the MCP flag off (so no tag is
|
||||
// needed) and the flag is enabled for pier.Mount afterwards.
|
||||
// http.yaml have defaults) and publishes the fake verifier and, unless
|
||||
// guard is nil, a guard registry with that provider; the router is built
|
||||
// with the MCP flag off (so no tag is needed) and the flag is enabled for
|
||||
// pier.Mount afterwards.
|
||||
func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -238,6 +247,9 @@ func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backp
|
||||
if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if guard == nil {
|
||||
return app
|
||||
}
|
||||
reg := pact.NewMCPGuards()
|
||||
if err := reg.Register(guard); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -260,7 +272,11 @@ func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHo
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := &testHost{r: router, handlers: map[string]http.Handler{}}
|
||||
return host, pier.Mount(host, app, plugins)
|
||||
err = pier.Mount(host, app, plugins)
|
||||
if err == nil {
|
||||
host.pier, _ = app.Lookup[*pier.Pier]()
|
||||
}
|
||||
return host, err
|
||||
}
|
||||
|
||||
// bearerTransport adds the bearer to every request of the SDK client.
|
||||
@@ -397,4 +413,6 @@ func TestPierTracerSmoke(t *testing.T) {
|
||||
if err := pier.Mount(offHost, off, plugins); err != nil || len(offHost.handlers) != 0 {
|
||||
t.Fatalf("mcp.enabled false: err = %v, handlers = %d", err, len(offHost.handlers))
|
||||
}
|
||||
|
||||
tracerIdentityCases(t, ctx)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ Use cases:
|
||||
- Request log, on by default: every request writes one Info record `http request` from the [diary](../diary/README.md) component `http` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status`, `duration_ms` and `request_id` (the `X-Request-Id` header, trimmed and cut to 128 bytes, left out when empty). A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else the `http` component logger, whose channels all redact through [sunscreen](../sunscreen/README.md). Silence it with `log.components.http: warn` or turn it off with `http.access_log: false`.
|
||||
- Startup summary and shutdown report for `serve`: once it listens, a block on a terminal (version, environment, URL and admin URL, database host and name, pending migrations, plugins, queue, scheduler, mail, cache, realtime and search drivers; `-v` adds one line per plugin) and, off a terminal, the same facts as a `startup summary` record and a `listening on <addr>` record of component `serve`; after SIGINT or SIGTERM, a report of requests served, drained and aborted, worker jobs, uptime and drain errors. Never a DSN user or password.
|
||||
- Streaming routes: `surf.Router.MountStream` registers a raw route whose response is not buffered, so the handler's `http.Flusher` reaches the client while it is still running (server-sent events, long-lived streams). Named middleware, `body.limit`, locale, the CORS profile and the access-log pattern apply as on a raw route; a panic before the first byte answers an opaque 500 and after it only cuts the body short. Used by the MCP module; plugin route groups stay buffered.
|
||||
- MCP module hook: with `mcp.enabled` true, `surf.BuildRouter` calls the mount hook the MCP module sets in a binary built with the `surf.MCPBuildTag` tag (`summercms_mcp` in `summer.yaml` `build.tags`), after every plugin route and the admin exist. Off (the default) mounts nothing and changes nothing; on without the tag fails boot with an error naming the tag.
|
||||
- MCP module hook: with `mcp.enabled` true, `surf.BuildRouter` calls the mount hook the MCP module sets in a binary built with the `surf.MCPBuildTag` tag (`summercms_mcp` in `summer.yaml` `build.tags`), after every plugin route and the admin exist. Off (the default) mounts nothing and changes nothing; on without the tag fails boot with an error naming the tag. The tagged `pier_on.go` sets the hook with an adapter that gives [pier](../pier/README.md) the route table, `surf.Router.Handler`, `surf.Router.MountStream` and a plain GET mount for its metadata documents, so pier never imports surf; its routes are owned by `summercms.pier`.
|
||||
- A read-only route table (`surf.Router.Routes`), one route's compiled chain (`surf.Router.Handler`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
## Usage
|
||||
|
||||
Reference in New Issue
Block a user