Files
summercms/modules/pier/session.go
Jakub Zych 034aa19904 fix(19.7): RA-IN-04 the non-reserving store fallback writes the record first
For a store without Reserve (only a test wrapper reaches this; both shipped
stores reserve), serveNew now calls Put right after the Count check and
before the server is built, and a failed connect or initialize deletes the
record again. The session is therefore never in the local map without its
record, so a reaper pass in between cannot close it as expired. The cap of
this fallback stays check-then-act, as its comment now says.
2026-10-23 20:22:58 +02:00

773 lines
24 KiB
Go

package pier
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Protocol constants the router reads.
const (
sessionIDHeader = "Mcp-Session-Id"
protocolVersionHeader = "Mcp-Protocol-Version"
protocolVersion202607 = "2026-07-28"
protocolVersion202511 = "2025-11-25"
protocolVersion202503 = "2025-03-26"
methodInitialize = "initialize"
methodListTools = "tools/list"
methodCallTool = "tools/call"
methodListPrompts = "prompts/list"
methodGetPrompt = "prompts/get"
methodDiscover = "server/discover"
methodListen = "subscriptions/listen"
)
// ephemeralSession is the session value of the audit line of a tool call
// served on the stateful path without a session (serveEphemeral).
const ephemeralSession = "ephemeral"
// Keys of auth.TokenInfo.Extra set by the bearer middleware.
const (
extraIdentity = "pier.identity"
extraCall = "pier.call"
)
// sessionRouter is the stateful streamable HTTP path (D-25): sessions live
// in the Store, bound to the identity that opened them, and are served by
// a per-session SDK server on whichever instance receives the request,
// rehydrated from the stored session state when it is not local.
type sessionRouter struct {
p *Pier
mu sync.Mutex
local map[string]*localSession
}
// localSession is one session connected on this instance.
type localSession struct {
id string
binding string
created time.Time
server *mcp.Server
transport *mcp.StreamableServerTransport
ss *mcp.ServerSession
// mu serializes re-filtering (widening, fan-out recomputes) and guards
// fingerprint, tools and prompts; it may be held across guard checks,
// bounded by mcp.call_timeout. identity has its own box, so fan-out
// matching never waits on mu.
mu sync.Mutex
identity identityBox
fingerprint string // of the identity the tool set was last filtered for
tools map[string]bool
prompts map[string]bool
initialized atomic.Bool
persisted atomic.Bool
}
func newSessionRouter(p *Pier) *sessionRouter {
return &sessionRouter{p: p, local: map[string]*localSession{}}
}
func (sr *sessionRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id, ok := identityFromRequest(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sid := r.Header.Get(sessionIDHeader)
switch r.Method {
case http.MethodDelete:
sr.serveDelete(w, r, id, sid)
case http.MethodGet:
if sid == "" {
http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest)
return
}
ls, ok := sr.lookup(w, r, id, sid)
if !ok {
return
}
sr.serveStream(w, r, ls)
case http.MethodPost:
if sid == "" {
sr.serveNew(w, r, id)
return
}
ls, ok := sr.lookup(w, r, id, sid)
if !ok {
return
}
ls.transport.ServeHTTP(w, r)
sr.persist(r.Context(), ls)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
// serveNew handles a POST without a session id: an initialize opens a
// session (refused with 429 above mcp.sessions.max_per_binding); any other
// request is served by a throwaway connection and closed, as the SDK does,
// so a server/discover probe or a ping before initialize gets an answer.
func (sr *sessionRouter) serveNew(w http.ResponseWriter, r *http.Request, id Identity) {
body, err := io.ReadAll(r.Body)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
if !slices.Contains(peekMethods(body), methodInitialize) {
sr.serveEphemeral(w, r, id)
return
}
ctx := r.Context()
sid := rand.Text()
rec := Record{Binding: id.Binding, Created: time.Now().UTC()}
// The slot is reserved atomically before the server is built
// (A-WR-03). A store without Reserve (only a test wrapper; both shipped
// stores reserve) falls back to Count then Put, still before the server
// is built, so the session is never in sr.local without its record (a
// reaper pass would close it as expired); its cap stays check-then-act
// (RA-IN-04).
res, reserves := sr.p.store.(reserver)
if reserves {
ok, err := res.Reserve(ctx, id.Binding, sid, rec, sr.p.cfg.MaxPerBinding, sr.p.cfg.IdleTTL)
if err != nil {
sr.p.storeUnavailable(w, err)
return
}
if !ok {
sr.tooMany(w)
return
}
} else {
n, err := sr.p.store.Count(ctx, id.Binding)
if err != nil {
sr.p.storeUnavailable(w, err)
return
}
if n >= sr.p.cfg.MaxPerBinding {
sr.tooMany(w)
return
}
if err := sr.p.store.Put(ctx, sid, rec, sr.p.cfg.IdleTTL); err != nil {
sr.p.storeUnavailable(w, err)
return
}
}
release := func() {
if reserves {
_ = res.Release(context.WithoutCancel(ctx), id.Binding, sid)
} else {
_ = sr.p.store.Delete(context.WithoutCancel(ctx), sid)
}
}
// The server is built with no router lock held: its guard checks may
// query a database (A-WR-07). The id is new, so nothing else holds it.
ls, err := sr.connect(ctx, sid, id, nil)
if err != nil {
release()
logger().Error("pier: session connect failed", "error", err)
http.Error(w, "failed connection", http.StatusInternalServerError)
return
}
ls.created = rec.Created
sr.mu.Lock()
sr.local[sid] = ls
sr.mu.Unlock()
logSession(logger(), "open", sid, id.Binding)
ls.transport.ServeHTTP(w, r)
if ls.ss.InitializeParams() == nil {
// initialize failed: nothing to keep (the SDK cleans up the same
// way), and the reserved slot frees.
release()
sr.closeLocal(sid)
return
}
sr.persist(ctx, ls)
}
// tooMany answers 429 for an initialize past mcp.sessions.max_per_binding.
func (sr *sessionRouter) tooMany(w http.ResponseWriter) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "too_many_sessions", "max_per_binding": sr.p.cfg.MaxPerBinding})
}
// serveEphemeral answers one request outside any session with the state
// the SDK synthesises for a pre-initialize request.
func (sr *sessionRouter) serveEphemeral(w http.ResponseWriter, r *http.Request, id Identity) {
// The audit line of a tool call served here names the session
// "ephemeral", not "stateless": it ran on the stateful path without a
// session. The token info is built per request, so it is ours to mark.
if ti := auth.TokenInfoFromContext(r.Context()); ti != nil && ti.Extra != nil {
info, _ := ti.Extra[extraCall].(callInfo)
info.session = ephemeralSession
ti.Extra[extraCall] = info
}
pv := r.Header.Get(protocolVersionHeader)
if pv == "" || pv >= protocolVersion202607 {
pv = protocolVersion202503
}
srv, _, _ := sr.p.newServer(r.Context(), id, nil)
tr := &mcp.StreamableServerTransport{}
state := &mcp.ServerSessionState{
InitializeParams: &mcp.InitializeParams{ProtocolVersion: pv},
InitializedParams: &mcp.InitializedParams{},
LogLevel: mcp.LoggingLevel("info"),
}
ss, err := srv.Connect(r.Context(), tr, &mcp.ServerSessionOptions{State: state})
if err != nil {
http.Error(w, "failed connection", http.StatusInternalServerError)
return
}
defer ss.Close()
tr.ServeHTTP(w, r)
}
// serveDelete ends a session everywhere: the record is removed, every
// instance is told and the local copy is closed.
func (sr *sessionRouter) serveDelete(w http.ResponseWriter, r *http.Request, id Identity, sid string) {
if sid == "" {
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
return
}
ctx := r.Context()
rec, ok, err := sr.p.store.Get(ctx, sid)
if err != nil {
sr.p.storeUnavailable(w, err)
return
}
if !ok {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if rec.Binding != id.Binding {
logSession(logger(), "binding_refused", sid, id.Binding)
writeJSON(w, http.StatusForbidden, map[string]any{"error": "session_binding"})
return
}
if err := sr.p.store.Delete(ctx, sid); err != nil {
sr.p.storeUnavailable(w, err)
return
}
_ = sr.p.store.Publish(ctx, Event{Kind: EventEnded, SessionID: sid, Instance: sr.p.instance})
sr.closeLocal(sid)
logSession(logger(), "end", sid, id.Binding)
w.WriteHeader(http.StatusNoContent)
}
// lookup finds the session of a request: 404 when the record is gone (the
// client re-initialises), 403 when the binding differs, else the local
// copy, rehydrated from the stored state when this instance has none; the
// record's TTL slides on every request.
func (sr *sessionRouter) lookup(w http.ResponseWriter, r *http.Request, id Identity, sid string) (*localSession, bool) {
ctx := r.Context()
rec, ok, err := sr.p.store.Get(ctx, sid)
if err != nil {
sr.p.storeUnavailable(w, err)
return nil, false
}
if !ok {
sr.closeLocal(sid)
http.Error(w, "session not found", http.StatusNotFound)
return nil, false
}
if rec.Binding != id.Binding {
logSession(logger(), "binding_refused", sid, id.Binding)
writeJSON(w, http.StatusForbidden, map[string]any{"error": "session_binding"})
return nil, false
}
sr.mu.Lock()
ls := sr.local[sid]
sr.mu.Unlock()
if ls == nil {
if len(rec.State) == 0 {
http.Error(w, "session not found", http.StatusNotFound)
return nil, false
}
var state mcp.ServerSessionState
if err := json.Unmarshal(rec.State, &state); err != nil || state.InitializeParams == nil {
http.Error(w, "session not found", http.StatusNotFound)
return nil, false
}
// Rehydrate with no router lock held (A-WR-07), then insert unless
// a concurrent request of the same session got there first.
built, err := sr.connect(ctx, sid, id, &state)
if err != nil {
logger().Error("pier: session rehydrate failed", "error", err)
http.Error(w, "failed connection", http.StatusInternalServerError)
return nil, false
}
built.created = rec.Created
if state.InitializedParams != nil {
built.persisted.Store(true)
}
sr.mu.Lock()
if cur := sr.local[sid]; cur != nil {
sr.mu.Unlock()
_ = built.ss.Close()
ls = cur
} else {
sr.local[sid] = built
sr.mu.Unlock()
ls = built
logSession(logger(), "rehydrate", sid, id.Binding)
}
}
ls.mu.Lock()
ls.identity.set(id)
sr.p.widen(ctx, ls.server, id, &ls.fingerprint, ls.tools, ls.prompts)
ls.mu.Unlock()
if err := sr.p.store.Touch(ctx, sid, sr.p.cfg.IdleTTL); err != nil {
sr.p.storeUnavailable(w, err)
return nil, false
}
return ls, true
}
// connect builds the per-identity server and connects it over a transport
// with the session id, from the stored state when rehydrating. A goroutine
// drops the local entry when the session closes.
func (sr *sessionRouter) connect(ctx context.Context, sid string, id Identity, state *mcp.ServerSessionState) (*localSession, error) {
ls := &localSession{id: sid, binding: id.Binding, created: time.Now().UTC(), identity: identityBox{id: id}, fingerprint: id.fingerprint()}
srv, tools, prompts := sr.p.newServer(ctx, id, func() { ls.initialized.Store(true) })
ls.server, ls.tools, ls.prompts = srv, tools, prompts
ls.transport = &mcp.StreamableServerTransport{SessionID: sid}
var opts *mcp.ServerSessionOptions
if state != nil {
opts = &mcp.ServerSessionOptions{State: state}
if state.InitializedParams != nil {
ls.initialized.Store(true)
}
}
ss, err := srv.Connect(context.WithoutCancel(ctx), ls.transport, opts)
if err != nil {
return nil, err
}
ls.ss = ss
go func() {
_ = ss.Wait()
sr.mu.Lock()
if sr.local[sid] == ls {
delete(sr.local, sid)
}
sr.mu.Unlock()
}()
return ls, nil
}
// persist writes the session state once initialize answered and again once
// the client's initialized notification arrived, so another instance can
// rehydrate a usable session.
func (sr *sessionRouter) persist(ctx context.Context, ls *localSession) {
if ls.persisted.Load() {
return
}
params := ls.ss.InitializeParams()
if params == nil {
return
}
state := mcp.ServerSessionState{
InitializeParams: params,
NegotiatedProtocolVersion: negotiatedVersion(params.ProtocolVersion),
LogLevel: mcp.LoggingLevel("info"),
}
complete := ls.initialized.Load()
if complete {
state.InitializedParams = &mcp.InitializedParams{}
}
raw, err := json.Marshal(state)
if err != nil {
return
}
rec := Record{Binding: ls.binding, State: raw, Created: ls.created}
stored := true
// The record is only rewritten, never created here: a session that ended
// while this request ran (DELETE, revocation, deletion, expiry) stays
// ended instead of being written back by its last request.
if u, ok := sr.p.store.(updater); ok {
stored, err = u.Update(ctx, ls.id, rec, sr.p.cfg.IdleTTL)
} else {
err = sr.p.store.Put(ctx, ls.id, rec, sr.p.cfg.IdleTTL)
}
if err != nil {
logger().Warn("pier: session state not stored", "session", sessionPrefix(ls.id), "error", err)
return
}
if !stored {
sr.closeLocal(ls.id)
return
}
if complete {
ls.persisted.Store(true)
}
}
// serveStream serves the standalone GET stream and re-verifies its bearer
// every recheck interval on a goroutine that holds the raw bearer only for
// the stream's lifetime; a bearer that no longer resolves to the session's
// binding ends the stream (Pitfall 10). The re-check resolves with the
// stream's client address on the context, as the bearer middleware does, so
// a failed re-check is logged with it. The server's base context ends
// every stream at shutdown.
func (sr *sessionRouter) serveStream(w http.ResponseWriter, r *http.Request, ls *localSession) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
raw := bearerOf(r)
ip := sr.p.clientIP(r)
go func() {
ticker := time.NewTicker(sr.p.recheck)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
id, err := sr.p.identities.resolve(towel.WithClientIP(ctx, ip), raw)
if err != nil || id.Binding != ls.binding {
logSession(logger(), "stream_closed", ls.id, ls.binding)
cancel()
return
}
// A record that is gone (an ended event lost between instances,
// an expiry) ends the stream and the local copy.
if _, ok, err := sr.p.store.Get(ctx, ls.id); err == nil && !ok {
logSession(logger(), "stream_closed", ls.id, ls.binding)
cancel()
sr.closeLocal(ls.id)
return
}
_ = sr.p.store.Touch(ctx, ls.id, sr.p.cfg.IdleTTL)
}
}
}()
ls.transport.ServeHTTP(w, r.WithContext(ctx))
}
// bearerOf reads the bearer of a request's Authorization header.
func bearerOf(r *http.Request) string {
fields := strings.Fields(r.Header.Get("Authorization"))
if len(fields) != 2 || !strings.EqualFold(fields[0], "bearer") {
return ""
}
return fields[1]
}
// closeLocal closes and forgets this instance's copy of a session.
func (sr *sessionRouter) closeLocal(sid string) {
sr.mu.Lock()
ls := sr.local[sid]
delete(sr.local, sid)
sr.mu.Unlock()
if ls != nil && ls.ss != nil {
_ = ls.ss.Close()
}
}
// reap closes, every interval until ctx ends, the local sessions whose
// store record has expired (P-24, A-CR-01). The store's TTL is the single
// source of truth across instances: a session another instance keeps alive
// still has its record, so this instance keeps its copy.
func (sr *sessionRouter) reap(ctx context.Context, every time.Duration) {
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sr.reapOnce(ctx)
}
}
}
// reapOnce runs one reaper pass: each local session whose record is gone is
// closed (its SDK server session ends, it leaves the local map and so the
// fan-out matching set) and logged as expired. A store error keeps the
// local copy (never close on doubt) and is logged once per pass. It returns
// how many sessions it closed.
func (sr *sessionRouter) reapOnce(ctx context.Context) int {
type entry struct{ id, binding string }
sr.mu.Lock()
entries := make([]entry, 0, len(sr.local))
for id, ls := range sr.local {
entries = append(entries, entry{id, ls.binding})
}
sr.mu.Unlock()
closed := 0
warned := false
for _, e := range entries {
if ctx.Err() != nil {
break
}
_, ok, err := sr.p.store.Get(ctx, e.id)
if err != nil {
if !warned {
logger().Warn("pier: session reaper could not read the store", "error", err)
warned = true
}
continue
}
if ok {
continue
}
sr.closeLocal(e.id)
logSession(logger(), "expired", e.id, e.binding)
closed++
}
return closed
}
// reapInterval is how often the reaper runs: half the idle TTL, at most a
// minute and at least a second.
func reapInterval(idleTTL time.Duration) time.Duration {
return max(min(idleTTL/2, time.Minute), time.Second)
}
// negotiatedVersion mirrors the SDK's handshake rule: the client's version
// when the SDK supports it and it predates 2026-07-28, else the newest
// handshake-era version.
func negotiatedVersion(client string) string {
supported := mcp.SupportedProtocolVersions()
if slices.Contains(supported, client) && client < protocolVersion202607 {
return client
}
for _, v := range supported {
if v < protocolVersion202607 {
return v
}
}
return protocolVersion202511
}
// peekMethods lists the JSON-RPC methods of a request body (one message or
// a batch) without consuming it; a malformed body lists none.
func peekMethods(body []byte) []string {
type msg struct {
Method string `json:"method"`
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil
}
if trimmed[0] == '[' {
var batch []msg
if err := json.Unmarshal(trimmed, &batch); err != nil {
return nil
}
out := make([]string, 0, len(batch))
for _, m := range batch {
out = append(out, m.Method)
}
return out
}
var m msg
if err := json.Unmarshal(trimmed, &m); err != nil {
return nil
}
return []string{m.Method}
}
// newServer builds one SDK server for an identity: the tools and prompts
// allowed(...) grants it, the receiving middleware that re-checks every
// list and call for the current request's identity, and the handlers that
// dispatch calls and render prompts. It returns the server and the names
// it holds, for later diffs.
func (p *Pier) newServer(ctx context.Context, id Identity, onInitialized func()) (*mcp.Server, map[string]bool, map[string]bool) {
opts := &mcp.ServerOptions{
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{ListChanged: true},
Prompts: &mcp.PromptCapabilities{ListChanged: true},
},
}
if onInitialized != nil {
opts.InitializedHandler = func(context.Context, *mcp.InitializedRequest) { onInitialized() }
}
srv := mcp.NewServer(&mcp.Implementation{Name: p.appName, Version: p.version}, opts)
srv.AddReceivingMiddleware(p.receiving(id))
tools, prompts := p.catalog.allowed(ctx, id)
toolSet := map[string]bool{}
for _, t := range tools {
srv.AddTool(t.desc, p.toolHandler(t, id))
toolSet[t.name] = true
}
promptSet := map[string]bool{}
for _, pr := range prompts {
srv.AddPrompt(pr.desc, p.promptHandler(pr))
promptSet[pr.name] = true
}
return srv, toolSet, promptSet
}
// receiving is the per-request re-check (D-08, D-25): tools/list and
// prompts/list are filtered for the identity of the request that carried
// them, and tools/call or prompts/get for an item outside that list is a
// JSON-RPC invalid-params error with a fixed text.
func (p *Pier) receiving(session Identity) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
switch method {
case methodListTools, methodListPrompts, methodCallTool, methodGetPrompt:
default:
return next(ctx, method, req)
}
id, _ := identityFromExtra(req.GetExtra(), session)
tools, prompts := p.catalog.allowed(ctx, id)
switch method {
case methodCallTool:
params, _ := req.GetParams().(*mcp.CallToolParamsRaw)
if params == nil || !containsTool(tools, params.Name) {
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "pier: tool is not available to this caller"}
}
return next(ctx, method, req)
case methodGetPrompt:
params, _ := req.GetParams().(*mcp.GetPromptParams)
if params == nil || !containsPrompt(prompts, params.Name) {
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "pier: prompt is not available to this caller"}
}
return next(ctx, method, req)
case methodListTools:
res, err := next(ctx, method, req)
if err != nil {
return nil, err
}
if lr, ok := res.(*mcp.ListToolsResult); ok {
kept := make([]*mcp.Tool, 0, len(lr.Tools))
for _, t := range lr.Tools {
if containsTool(tools, t.Name) {
kept = append(kept, t)
}
}
lr.Tools = kept
}
return res, nil
default:
res, err := next(ctx, method, req)
if err != nil {
return nil, err
}
if lr, ok := res.(*mcp.ListPromptsResult); ok {
kept := make([]*mcp.Prompt, 0, len(lr.Prompts))
for _, pr := range lr.Prompts {
if containsPrompt(prompts, pr.Name) {
kept = append(kept, pr)
}
}
lr.Prompts = kept
}
return res, nil
}
}
}
}
func containsTool(tools []*tool, name string) bool {
for _, t := range tools {
if t.name == name {
return true
}
}
return false
}
func containsPrompt(prompts []*prompt, name string) bool {
for _, p := range prompts {
if p.name == name {
return true
}
}
return false
}
// toolHandler dispatches one call as the current request's identity.
func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, info := identityFromExtra(req.Extra, session)
if req.Session != nil && req.Session.ID() != "" {
info.session = req.Session.ID()
}
return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil
}
}
// promptRenderFailed is the JSON-RPC error text of a prompt whose Render
// failed; the cause is logged, never sent (RA-WR-05).
const promptRenderFailed = "pier: the prompt could not be rendered"
// promptHandler checks the required arguments and renders the prompt. A
// Render error is logged server-side and answered with a fixed text, so a
// database or provider error never reaches the agent (as for dispatch).
func (p *Pier) promptHandler(pr *prompt) mcp.PromptHandler {
return func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
args := map[string]string{}
if req.Params != nil {
for k, v := range req.Params.Arguments {
args[k] = v
}
}
for _, a := range pr.decl.Arguments {
if a.Required && args[a.Name] == "" {
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("pier: prompt argument %q is required", a.Name)}
}
}
messages, err := pr.decl.Render(ctx, args)
if err != nil {
logger().Warn("pier: prompt render failed", "prompt", pr.name, "error", err)
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: promptRenderFailed}
}
res := &mcp.GetPromptResult{Description: pr.decl.Description, Messages: []*mcp.PromptMessage{}}
for _, m := range messages {
role := mcp.Role(m.Role)
if role != "assistant" {
role = "user"
}
res.Messages = append(res.Messages, &mcp.PromptMessage{Role: role, Content: &mcp.TextContent{Text: m.Text}})
}
return res, nil
}
}
// identityFromExtra reads the request's identity and call info off the
// token info the bearer middleware stored; the session's identity is the
// fallback for a request that carries none.
func identityFromExtra(extra *mcp.RequestExtra, fallback Identity) (Identity, callInfo) {
if extra == nil || extra.TokenInfo == nil || extra.TokenInfo.Extra == nil {
return fallback, callInfo{}
}
id, _ := extra.TokenInfo.Extra[extraIdentity].(Identity)
info, _ := extra.TokenInfo.Extra[extraCall].(callInfo)
if id.Binding == "" {
return fallback, info
}
return id, info
}
// writeJSON writes a small JSON body with a status.
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}