stacklok / stacklok/mecatl

mecatl: should MCP broker OAuth authorization be scoped to a principal/lineage instead of strictly per-session?

Open
#1,352 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
152
Forks
16
Avg merge
14h 48m
Merged PRs (30d)
536

Description

Summary

MCP broker OAuth authorization/enrollment state (ADR 0311) is keyed strictly
1:1 by mecatl session ID. Any operation that creates a new session ID for what
is conceptually the same principal continuing the same work — most notably
switching the active model/provider mid-conversation via ForkSession, but
also ClearSession — currently forces that new session to start with zero
broker authorization, even though nothing about the user's identity or
already-granted upstream consent has changed. The user has to re-run OAuth
enrollment for every protected MCP backend after every model switch.

This issue is about whether that scoping choice should be revisited, not about
the currently-broken mechanics of fork/clear in broker mode (that's a
separate, already-filed bug: #1351 — ForkSession/ClearSession don't even
mint a fresh binding correctly today, let alone consider retaining the old
one). Once #1351 is fixed, forking will work, but every fork will still cold-
start unauthorized. This issue asks: should it?

Where the scoping decision currently lives

  • internal/adapter/mcpbroker/runtime.go, Runtime.AttachSession (~line 273-275):
    "creates or reattaches to logical state keyed by the canonical mecatl session
    ID." The map is r.sessions[id] — one broker logical session per mecatl
    session ID, no other identity axis exists at this layer.
  • docs/adr/0311-per-upstream-mcp-broker-oauth-grants.md: explicitly scopes
    itself as "session-scoped MCP broker OAuth authorization" and states mecatl
    "does not interpret that credential as an upstream grant... does not
    implement... grant copying." ToolHive alone owns the authorization chain;
    mecatl is deliberately not supposed to assert on its own that a credential
    valid in one context is valid in another.
  • session.ExternalBinding (engine/session/session.go:18) is the opaque
    handle mecatl persists to reattach to that same session-ID-keyed logical
    state (e.g. across a process restart of the same session).

Why this is worth reconsidering

A model-switch fork and the source session it forked from share the same
authenticated principal and the same conversation lineage — they are not two
different users, and not a scenario where reusing consent would cross a trust
boundary. Forcing a full re-authorization dance on every model switch is a
real, user-visible friction cost with no corresponding security benefit that I
can identify: the thing OAuth grants are actually about (a principal's consent
for a backend) hasn't changed; only the LLM provider/model selection has.

Evidence that "reuse" doesn't have to mean "copy"

Runtime.AttachSession already has first-class support for multiple
independent attachments to one logical session
— this is not something that
would need to be invented:

  • The reattach branch (runtime.go:302-309) explicitly handles a second
    attachment to an existing logical session, and already knows how to hand a
    freshly-attached handle an already-completed enrollment
    (runtime.go:323-330, installCompletedEnrollment) without any new
    authorization round-trip — this is exactly how a restarted process reattaches
    to a still-live broker session today.
  • Attachment.Close/Attachment.Abort (runtime.go:395-465) already
    distinguish a "creator" attachment from a "reattacher" one: aborting or
    closing a reattacher's handle never tears down the shared logical session
    underneath a sibling attachment that's still using it.

So a forked session picking up its source's already-completed enrollment would
not require mecatl to assert or copy anything — it would be the same
ToolHive-verified state, opened by a second handle, using a mechanism the
runtime already relies on for a different case (restart-reattach). That reframes
the question away from "should mecatl duplicate an authorization decision"
(clearly wrong, per ADR 0311) toward "should two deliberately parallel
mecatl sessions be allowed to hold concurrent attachments to the same broker
logical incarnation" (not yet decided either way).

What stands in the way — this is a real design gap, not a small patch

mecatl's own session lifecycle assumes 1:1, not N:1, between a mecatl session
and a broker logical incarnation:

  • Service.DeleteSession (internal/adapter/server/service.go:3440) keys the
    broker-side delete by the mecatl session's own ID. If two sessions shared one
    broker binding, deleting either one today would either miss the shared state
    entirely, or destroy it out from under the sibling still using it — e.g.
    deleting your old pre-switch session would silently deauthorize the session
    you switched to. There is no reference-counting or "detach without delete"
    concept today.
  • Snapshot/rehydration and GC likewise assume ExternalBinding uniquely and
    permanently identifies exactly one mecatl session's own broker state.
  • ADR 0311 explicitly frames the current design as session-scoped; widening
    that is a change to a stated architectural decision, not a bugfix — by this
    repo's own conventions (see AGENTS.md's ADR lifecycle rules) that means a
    new or superseding ADR, not a quiet code change.

Possible high-level directions (not evaluated in depth — for the next investigator)

These are sketched at a conceptual level only; no implementation approach is
being proposed or endorsed here.

  1. Do nothing / keep strictly session-scoped. Accept the re-authorization
    cost as the price of strict per-session isolation. Simplest, but leaves the
    UX friction in place indefinitely, and every future session-forking feature
    (not just model-switch) inherits the same cost.
  2. Introduce a separate "authorization lineage" identity, distinct from the
    mecatl session ID, that the broker keys on instead.
    E.g. a stable id
    established once per top-level conversation and carried forward through
    fork/clear (but NOT through genuinely new/unrelated sessions). The broker's
    AttachSession would key on this lineage id; mecatl session IDs remain the
    unit of LLM/provider/history identity as they are today. This directly
    solves the reattachment case above, but requires solving the N:1
    delete/GC/lifecycle-ownership problem explicitly (e.g. explicit
    detach-vs-delete semantics, or last-referencing-session-triggers-delete).
  3. Keep binding == session ID, but let fork/clear explicitly reuse the
    source's existing binding value instead of minting a new one
    , accepting
    that the mecatl session ID and the broker binding are no longer always
    equal for a forked session. Smaller conceptually than (2) but has the same
    underlying delete/lifecycle problem, just pushed onto the binding field's
    uniqueness assumption instead of a new identity type.
  4. Leave grants session-scoped, but shrink the friction another way — e.g.
    verify whether ToolHive's own upstream-token storage (which already
    resolves a canonical user identity distinct from the mecatl session ID,
    per maybeCarryForwardRefreshToken in the vendored
    github.com/stacklok/toolhive/pkg/authserver/server/handlers/callback.go)
    already makes a second interactive authorization for the same real user
    fast (e.g. instant redirect through an active upstream IdP session, no new
    consent screen) even without any mecatl-level state sharing. If so, part of
    the perceived problem may already be substantially mitigated by ToolHive's
    own behavior, and the real gap is smaller than it first appears. Worth
    confirming empirically before investing in (2) or (3).

Explicitly out of scope for a fix here

  • Any change to who owns the OAuth chain (ToolHive continues to own it per
    ADR 0311 — this issue is only about how many mecatl sessions may reference
    one already-completed authorization, not about mecatl acquiring or
    interpreting credentials itself).
  • The already-filed, purely mechanical bug in #1351 (fork/clear currently
    fails outright in broker mode; fix that first regardless of this issue's
    outcome).

Ask

Investigate which of the directions above (or another not listed) is the right
one, whether it needs a new ADR (my read: yes, given ADR 0311's explicit
"session-scoped" framing), and produce a recommendation — this issue
deliberately stops short of proposing one so that analysis starts fresh rather
than anchoring on my framing.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read docs/adr/0311-per-upstream-mcp-broker-oauth-grants.md and the ADR lifecycle rules in AGENTS.md first. Trace Runtime.AttachSession in internal/adapter/mcpbroker/runtime.go alongside Service.DeleteSession in internal/adapter/server/service.go, then assess the proposed scoping options and ToolHive token behavior. Done means a recommendation on authorization scope and lifecycle ownership, with a new or superseding ADR if the decision changes.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
authentication, backend, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.