package pier import ( "context" "git.golem15.com/golem15/summercms/modules/pact" ) // listenEvents republishes every pact.MCPSubjectChanged fired on the // application through the store, so every instance (this one included) // applies it in handleEvent; when publishing fails the event is applied // locally at least. func (p *Pier) listenEvents() { if p.app == nil || p.app.Events == nil { return } p.app.Events.Listen(PluginID, func(ctx context.Context, ev *pact.MCPSubjectChanged) error { if ev == nil || ev.Kind == "" { return nil } event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance} if err := p.store.Publish(ctx, event); err != nil { p.handleEvent(event) } return nil }) } // handleEvent applies one event on this instance (D-26, D-27): a session // ended elsewhere is closed here; a permission or link change recomputes // the tools and prompts of every local session and stateless server of // that subject, which makes the SDK send the list_changed notifications; a // revocation or deletion closes those sessions, drops their records and // evicts the stateless servers. A link event also drops the link cache. func (p *Pier) handleEvent(ev Event) { ctx := context.Background() switch ev.Kind { case EventEnded: if ev.SessionID != "" && ev.Instance != p.instance { p.sessions.closeLocal(ev.SessionID) } case pact.MCPSubjectRevoked, pact.MCPSubjectDeleted: for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) { _ = p.store.Delete(ctx, ls.id) p.sessions.closeLocal(ls.id) logSession(logger(), "revoked", ls.id, ls.binding) } if p.stateless != nil { p.stateless.evict(ev.Guard, ev.UserID) } case pact.MCPSubjectLink, pact.MCPSubjectPermissions: if ev.Kind == pact.MCPSubjectLink { if ev.Guard == BackendGuard { p.identities.forgetAllLinks() } else { p.identities.forgetLink(ev.UserID) } } for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) { p.recomputeSession(ctx, ls, ev.Kind == pact.MCPSubjectLink) } if p.stateless != nil { for _, e := range p.stateless.matching(ev.Guard, ev.UserID) { p.recomputeStateless(ctx, e, ev.Kind == pact.MCPSubjectLink) } } } } // recomputeSession re-filters a local session's identity (re-resolving // the backend link after a link event) and applies the difference with // AddTool, RemoveTools, AddPrompt and RemovePrompts. func (p *Pier) recomputeSession(ctx context.Context, ls *localSession, relink bool) { ls.mu.Lock() id := ls.identity if relink { p.identities.refreshBackend(ctx, &id) ls.identity = id } tools, prompts := p.catalog.allowed(ctx, id) applyDiff(ls.server, p, id, tools, prompts, ls.tools, ls.prompts) ls.fingerprint = id.fingerprint() ls.mu.Unlock() } // recomputeStateless does the same for one per-identity stateless server. func (p *Pier) recomputeStateless(ctx context.Context, e *statelessEntry, relink bool) { e.mu.Lock() id := e.identity if relink { p.identities.refreshBackend(ctx, &id) e.identity = id } tools, prompts := p.catalog.allowed(ctx, id) applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts) e.fingerprint = id.fingerprint() e.mu.Unlock() } // widen re-filters a cached server for the identity of the request that // reached it when that identity's fingerprint differs from the one the // server was last filtered for (A-WR-04): a binding that returns with more // scope, or with a backend subject added, gains the newly allowed tools and // prompts at once, and one that returns with less loses them. The caller // holds the session's or entry's own mutex, never a router-wide one. func (p *Pier) widen(ctx context.Context, srv serverFeatures, id Identity, fp *string, haveTools, havePrompts map[string]bool) { next := id.fingerprint() if *fp == next { return } // A client that hangs up mid-check must not leave the server filtered // by cancelled guard checks (an error hides the item). tools, prompts := p.catalog.allowed(context.WithoutCancel(ctx), id) applyDiff(srv, p, id, tools, prompts, haveTools, havePrompts) *fp = next } // applyDiff brings a server's tool and prompt sets to the allowed ones, // updating the tracking maps in place. func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) { wantTools := map[string]bool{} for _, t := range tools { wantTools[t.name] = true if !haveTools[t.name] { srv.AddTool(t.desc, p.toolHandler(t, id)) haveTools[t.name] = true } } var dropTools []string for name := range haveTools { if !wantTools[name] { dropTools = append(dropTools, name) delete(haveTools, name) } } if len(dropTools) > 0 { srv.RemoveTools(dropTools...) } wantPrompts := map[string]bool{} for _, pr := range prompts { wantPrompts[pr.name] = true if !havePrompts[pr.name] { srv.AddPrompt(pr.desc, p.promptHandler(pr)) havePrompts[pr.name] = true } } var dropPrompts []string for name := range havePrompts { if !wantPrompts[name] { dropPrompts = append(dropPrompts, name) delete(havePrompts, name) } } if len(dropPrompts) > 0 { srv.RemovePrompts(dropPrompts...) } } // matching lists the local sessions whose identity includes the subject. func (sr *sessionRouter) matching(guard string, userID uint) []*localSession { sr.mu.Lock() defer sr.mu.Unlock() var out []*localSession for _, ls := range sr.local { ls.mu.Lock() hit := ls.identity.has(guard, userID) ls.mu.Unlock() if hit { out = append(out, ls) } } return out } // refreshBackend re-resolves an OAuth identity's backend subject through // the link resolver (after a link event), adding or dropping it. func (r *identityResolver) refreshBackend(ctx context.Context, id *Identity) { if id.TokenKind != TokenKindOAuth || id.Frontend == nil { return } id.Backend = nil for _, s := range id.Scopes { if s != "backend" { continue } if link, ok := r.linkedBackend(ctx, id.Frontend.UserID); ok { id.Backend = &pact.MCPSubject{ Guard: BackendGuard, UserID: link.ID, Scopes: id.Scopes, ClientID: id.Frontend.ClientID, TokenID: id.Frontend.TokenID, } } return } } // forgetAllLinks drops the whole link cache. func (r *identityResolver) forgetAllLinks() { r.mu.Lock() r.links = map[uint]linkEntry{} r.mu.Unlock() }