package pier import ( "fmt" "net/http" "reflect" "regexp" "slices" "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 tool|prompt : ..." 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 } // Over HTTP a {segment} never matches an empty value, so a path // field the schema lets the agent omit is refused (A-WR-02). if !slices.Contains(schema.Required, f.name) { return nil, fmt.Errorf("%s: path field %q must be required (drop omitempty/omitzero)", errPrefix, f.name) } 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) } } // An embedded struct's promoted fields are in the schema but would // never be sent, since the request has no body (A-IN-07). if name, ok := embeddedBodyField(rt); ok { return nil, fmt.Errorf("%s: embedded field %q would promote request body fields, but a %s route takes only path and query fields", errPrefix, 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 } // embeddedBodyField names the first embedded struct field of the input // that carries no path or query tag and is not hidden with json:"-": its // promoted fields would be body fields. func embeddedBodyField(rt reflect.Type) (string, bool) { for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) if !f.Anonymous { continue } if tag, ok := f.Tag.Lookup("json"); ok { if n, _, _ := strings.Cut(tag, ","); n == "-" { continue } } if _, ok := f.Tag.Lookup("path"); ok { continue } if _, ok := f.Tag.Lookup("query"); ok { continue } ft := f.Type for ft.Kind() == reflect.Pointer { ft = ft.Elem() } if ft.Kind() == reflect.Struct { return f.Name, true } } return "", false } // 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 }