- 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
456 lines
14 KiB
Go
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
|
|
}
|