Files
summercms/modules/pier/catalog.go
Jakub Zych cfdbaf0e47 feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions
- modules/pier: Host contract, mcp.* config, catalog compiled from
  pact.HasMCP (prefixing, route binding, guard resolution, schema
  reflection, annotations), per-request identity through the published
  verifier and resolvers, built-in backend guard provider, in-process
  dispatch with the bouncer marker, per-identity SDK server with the
  per-request re-filter, stateful session router with rehydration over a
  memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
  pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
  before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
  through surf's compiled guard chain; README and root table row
2026-10-22 18:15:59 +02:00

456 lines
14 KiB
Go

package pier
import (
"fmt"
"net/http"
"reflect"
"regexp"
"sort"
"strings"
"sync"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MaxDescriptionRunes caps a tool or prompt description; a longer one fails
// boot (descriptions are static text, never built from data).
const MaxDescriptionRunes = 1024
// exposedName is what an exposed tool or prompt name must match: the SDK
// only logs an invalid name, pier refuses it at boot (Pitfall 11).
var exposedName = regexp.MustCompile(`^[a-z0-9_]{1,128}$`)
// argKind says where one input field goes on the dispatched request.
type argKind int
const (
argBody argKind = iota
argPath
argQuery
)
// argField is one top-level field of a tool's input struct.
type argField struct {
name string // JSON property name
kind argKind // body, path or query
key string // the {segment} or query key for path and query fields
}
// tool is one compiled tool: the plugin's declaration bound to a route, the
// guard that runs it, the requirements the route's middleware implies, the
// reflected and resolved input schema, the SDK descriptor and the lazily
// resolved compiled handler.
type tool struct {
name string
pluginID string
decl pact.MCPTool
method string
pattern string
guardName string
guard pact.MCPGuard
req pact.MCPRequirements
schema *jsonschema.Schema
resolved *jsonschema.Resolved
fields []argField
segments []string
readOnly bool
desc *mcp.Tool
once sync.Once
handler http.Handler
hasH bool
}
// prompt is one compiled prompt: the declaration, its guard and the SDK
// descriptor.
type prompt struct {
name string
pluginID string
decl pact.MCPPrompt
guardName string
guard pact.MCPGuard
desc *mcp.Prompt
}
// catalog is every tool and prompt the activated plugins declare, keyed by
// exposed name, in declaration order.
type catalog struct {
tools []*tool
byName map[string]*tool
prompts []*prompt
promptByName map[string]*prompt
owners map[string]string // exposed name -> plugin id, tools and prompts
}
// compileCatalog validates every pact.HasMCP declaration against the host's
// route table and the guard registry. Zero declaring plugins is fine. Every
// failure is "pier: plugin <id> tool|prompt <name>: ..." and fails boot.
func compileCatalog(host Host, guards *pact.MCPGuards, plugins []party.Plugin) (*catalog, error) {
c := &catalog{
byName: map[string]*tool{},
promptByName: map[string]*prompt{},
owners: map[string]string{},
}
routes := map[string]RouteInfo{}
for _, ri := range host.Routes() {
routes[ri.Method+" "+ri.Pattern] = ri
}
for _, p := range plugins {
hm, ok := p.(pact.HasMCP)
if !ok {
continue
}
decl := hm.MCP()
prefix := prefixFor(p.ID())
for _, t := range decl.Tools {
ct, err := compileTool(p.ID(), prefix, t, routes, guards)
if err != nil {
return nil, err
}
if owner, taken := c.owners[ct.name]; taken {
return nil, fmt.Errorf("pier: plugin %s tool %q: exposed name %q is already taken by plugin %s", p.ID(), t.Name, ct.name, owner)
}
c.owners[ct.name] = p.ID()
c.byName[ct.name] = ct
c.tools = append(c.tools, ct)
}
for _, pr := range decl.Prompts {
cp, err := compilePrompt(p.ID(), prefix, pr, guards)
if err != nil {
return nil, err
}
if owner, taken := c.owners[cp.name]; taken {
return nil, fmt.Errorf("pier: plugin %s prompt %q: exposed name %q is already taken by plugin %s", p.ID(), pr.Name, cp.name, owner)
}
c.owners[cp.name] = p.ID()
c.promptByName[cp.name] = cp
c.prompts = append(c.prompts, cp)
}
}
return c, nil
}
// prefixFor derives the tool prefix from a plugin id: the last dot-segment,
// lower-cased, every run of characters outside [a-z0-9] replaced by "_",
// plus a trailing "_" (acme.blog -> blog_).
func prefixFor(pluginID string) string {
code := pluginID
if i := strings.LastIndex(code, "."); i >= 0 {
code = code[i+1:]
}
code = strings.ToLower(code)
var b strings.Builder
run := false
for _, r := range code {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
run = false
continue
}
if !run {
b.WriteByte('_')
run = true
}
}
return strings.Trim(b.String(), "_") + "_"
}
func compileTool(pluginID, prefix string, t pact.MCPTool, routes map[string]RouteInfo, guards *pact.MCPGuards) (*tool, error) {
errPrefix := fmt.Sprintf("pier: plugin %s tool %q", pluginID, t.Name)
if strings.TrimSpace(t.Name) == "" {
return nil, fmt.Errorf("%s: the name is empty", errPrefix)
}
name := prefix + t.Name
if !exposedName.MatchString(name) {
return nil, fmt.Errorf("%s: exposed name %q must match %s", errPrefix, name, exposedName.String())
}
if utf8.RuneCountInString(t.Description) > MaxDescriptionRunes {
return nil, fmt.Errorf("%s: the description is longer than %d characters", errPrefix, MaxDescriptionRunes)
}
method := strings.ToUpper(strings.TrimSpace(t.Method))
pattern := strings.TrimSpace(t.Path)
route, ok := routes[method+" "+pattern]
if !ok {
return nil, fmt.Errorf("%s: binds to %s %s, which no plugin registers", errPrefix, method, pattern)
}
guardName, guard, err := resolveGuard(t.Guard, route.Middleware, guards)
if err != nil {
return nil, fmt.Errorf("%s: %w", errPrefix, err)
}
req, err := guard.MCPRequirements(route.Middleware)
if err != nil {
return nil, fmt.Errorf("%s: guard %s rejects the route's middleware: %w", errPrefix, guardName, err)
}
if t.Input == nil {
return nil, fmt.Errorf("%s: Input is nil (the zero value of the input struct is required)", errPrefix)
}
rt := reflect.TypeOf(t.Input)
for rt.Kind() == reflect.Pointer {
rt = rt.Elem()
}
if rt.Kind() != reflect.Struct {
return nil, fmt.Errorf("%s: Input must be a struct or a pointer to one, got %s", errPrefix, rt)
}
schema, err := jsonschema.ForType(rt, nil)
if err != nil {
return nil, fmt.Errorf("%s: input schema: %w", errPrefix, err)
}
if schema.Type != "object" {
return nil, fmt.Errorf("%s: input schema must be an object, got %q", errPrefix, schema.Type)
}
resolved, err := schema.Resolve(nil)
if err != nil {
return nil, fmt.Errorf("%s: input schema does not resolve: %w", errPrefix, err)
}
fields, err := inputFields(rt)
if err != nil {
return nil, fmt.Errorf("%s: %w", errPrefix, err)
}
segments := patternSegments(pattern)
tagged := map[string]bool{}
for _, f := range fields {
if f.kind != argPath {
continue
}
found := false
for _, s := range segments {
if s == f.key {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%s: field %q has tag path:%q but %s has no {%s} segment", errPrefix, f.name, f.key, pattern, f.key)
}
tagged[f.key] = true
}
for _, s := range segments {
if !tagged[s] {
return nil, fmt.Errorf("%s: segment {%s} of %s has no input field with tag path:%q", errPrefix, s, pattern, s)
}
}
if method == http.MethodGet || method == http.MethodDelete {
for _, f := range fields {
if f.kind == argBody {
return nil, fmt.Errorf("%s: field %q would be a request body, but a %s route takes only path and query fields", errPrefix, f.name, method)
}
}
}
var output *jsonschema.Schema
if t.Output != nil {
ot := reflect.TypeOf(t.Output)
for ot.Kind() == reflect.Pointer {
ot = ot.Elem()
}
output, err = jsonschema.ForType(ot, nil)
if err != nil {
return nil, fmt.Errorf("%s: output schema: %w", errPrefix, err)
}
}
ann := annotationsFor(method, t)
desc := &mcp.Tool{
Name: name,
Title: t.Title,
Description: t.Description,
InputSchema: schema,
Annotations: ann,
}
if output != nil {
desc.OutputSchema = output
}
return &tool{
name: name,
pluginID: pluginID,
decl: t,
method: method,
pattern: pattern,
guardName: guardName,
guard: guard,
req: req,
schema: schema,
resolved: resolved,
fields: fields,
segments: segments,
readOnly: ann.ReadOnlyHint,
desc: desc,
}, nil
}
func compilePrompt(pluginID, prefix string, p pact.MCPPrompt, guards *pact.MCPGuards) (*prompt, error) {
errPrefix := fmt.Sprintf("pier: plugin %s prompt %q", pluginID, p.Name)
if strings.TrimSpace(p.Name) == "" {
return nil, fmt.Errorf("%s: the name is empty", errPrefix)
}
name := prefix + p.Name
if !exposedName.MatchString(name) {
return nil, fmt.Errorf("%s: exposed name %q must match %s", errPrefix, name, exposedName.String())
}
if utf8.RuneCountInString(p.Description) > MaxDescriptionRunes {
return nil, fmt.Errorf("%s: the description is longer than %d characters", errPrefix, MaxDescriptionRunes)
}
if p.Render == nil {
return nil, fmt.Errorf("%s: Render is nil", errPrefix)
}
if strings.TrimSpace(p.Guard) == "" {
return nil, fmt.Errorf("%s: a prompt must name its Guard (%s)", errPrefix, strings.Join(guards.Names(), ", "))
}
guard, ok := guards.Lookup(p.Guard)
if !ok {
return nil, fmt.Errorf("%s: unknown guard %q (registered: %s)", errPrefix, p.Guard, strings.Join(guards.Names(), ", "))
}
seen := map[string]bool{}
desc := &mcp.Prompt{Name: name, Title: p.Title, Description: p.Description}
for _, a := range p.Arguments {
if strings.TrimSpace(a.Name) == "" {
return nil, fmt.Errorf("%s: an argument has an empty name", errPrefix)
}
if seen[a.Name] {
return nil, fmt.Errorf("%s: argument %q is declared twice", errPrefix, a.Name)
}
seen[a.Name] = true
desc.Arguments = append(desc.Arguments, &mcp.PromptArgument{Name: a.Name, Description: a.Description, Required: a.Required})
}
return &prompt{name: name, pluginID: pluginID, decl: p, guardName: p.Guard, guard: guard, desc: desc}, nil
}
// resolveGuard picks the guard a tool runs as: the declared one, which must
// be registered, or else the registered provider whose token-accepting
// middleware the route carries (D-15).
func resolveGuard(declared string, middleware []string, guards *pact.MCPGuards) (string, pact.MCPGuard, error) {
if declared != "" {
g, ok := guards.Lookup(declared)
if !ok {
return "", nil, fmt.Errorf("unknown guard %q (registered: %s)", declared, strings.Join(guards.Names(), ", "))
}
return declared, g, nil
}
for _, name := range guards.Names() {
g, _ := guards.Lookup(name)
if g != nil && g.MCPTokenGuarded(middleware) {
return name, g, nil
}
}
return "", nil, fmt.Errorf("the route is not token-guarded (middleware: %s) and the tool declares no Guard", strings.Join(middleware, ", "))
}
// inputFields classifies the exported top-level fields of the input struct
// by their path, query and json tags.
func inputFields(rt reflect.Type) ([]argField, error) {
var out []argField
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if !f.IsExported() {
continue
}
name := f.Name
if tag, ok := f.Tag.Lookup("json"); ok {
n, _, _ := strings.Cut(tag, ",")
if n == "-" {
continue
}
if n != "" {
name = n
}
}
pathKey, hasPath := f.Tag.Lookup("path")
queryKey, hasQuery := f.Tag.Lookup("query")
switch {
case hasPath && hasQuery:
return nil, fmt.Errorf("field %q has both a path and a query tag", f.Name)
case hasPath:
if pathKey == "" {
return nil, fmt.Errorf("field %q has an empty path tag", f.Name)
}
if f.Anonymous {
return nil, fmt.Errorf("field %q is embedded and cannot carry a path tag", f.Name)
}
out = append(out, argField{name: name, kind: argPath, key: pathKey})
case hasQuery:
if queryKey == "" {
return nil, fmt.Errorf("field %q has an empty query tag", f.Name)
}
if f.Anonymous {
return nil, fmt.Errorf("field %q is embedded and cannot carry a query tag", f.Name)
}
out = append(out, argField{name: name, kind: argQuery, key: queryKey})
default:
if f.Anonymous {
// jsonschema promotes an embedded struct's fields; they are
// body fields under their own names.
continue
}
out = append(out, argField{name: name, kind: argBody})
}
}
return out, nil
}
// patternSegments lists the {name} and {name...} segments of a route
// pattern in order; {$} is not a parameter.
func patternSegments(pattern string) []string {
var out []string
for _, seg := range strings.Split(pattern, "/") {
if len(seg) < 3 || seg[0] != '{' || seg[len(seg)-1] != '}' {
continue
}
name := strings.TrimSuffix(seg[1:len(seg)-1], "...")
if name == "$" || name == "" {
continue
}
out = append(out, name)
}
return out
}
// annotationsFor derives the annotations from the HTTP method (D-14) with
// the declaration's overrides; the destructive and open-world hints are
// always explicit.
func annotationsFor(method string, t pact.MCPTool) *mcp.ToolAnnotations {
readOnly := method == http.MethodGet
if t.ReadOnly != nil {
readOnly = *t.ReadOnly
}
idempotent := method == http.MethodGet || method == http.MethodPut
if t.Idempotent != nil {
idempotent = *t.Idempotent
}
destructive := method == http.MethodDelete
if t.Destructive != nil {
destructive = *t.Destructive
}
openWorld := false
if t.OpenWorld != nil {
openWorld = *t.OpenWorld
}
return &mcp.ToolAnnotations{
Title: t.Title,
ReadOnlyHint: readOnly,
IdempotentHint: idempotent,
DestructiveHint: &destructive,
OpenWorldHint: &openWorld,
}
}
// compiledHandler resolves the route's compiled chain once, after the host
// has compiled its routes.
func (t *tool) compiledHandler(host Host) (http.Handler, bool) {
t.once.Do(func() {
t.handler, t.hasH = host.Handler(t.method, t.pattern)
})
return t.handler, t.hasH
}
// toolNames lists exposed tool names, sorted.
func toolNames(tools []*tool) []string {
out := make([]string, 0, len(tools))
for _, t := range tools {
out = append(out, t.name)
}
sort.Strings(out)
return out
}