Skip to main content

ADR-0022: Auto-derived per-team MCP gateway (+ team/agent credential & guardrail scoping)

  • Status: Accepted (retroactively recorded). Originally accepted 2026-07-10 (founder approval on upsquad-ai/upsquad-core#1732); this markdown was backfilled 2026-07-26 via #2123 — the decision shipped without an ADR file ever landing (see Provenance).
  • Date: 2026-07-10 (decision) · 2026-07-26 (record)
  • Decision owners: Principal Architect (author, #1732); Ashik / Founder (approval comment 2026-07-10 on #1732); Backend SME (implementation, internal/gateway/mcpproxy, internal/mcpserver); Project Manager (milestone #24 delivery sign-off 2026-07-11)
  • Amends / extends: ADR-0021 — Registry & Gateway Architecture (MCP + LLM)
  • Related:
    • ADR issue (approved decision record): upsquad-ai/upsquad-core#1732
    • PRD upsquad-ai/upsquad-core#1681 (v1.1) — Registry & Gateway: governed MCP + LLM registries, team-scoped gateways, per-tool guardrails
    • Milestone #24 (MCP Access & Credential Scoping Rework)
    • Implementation waves (all merged, see As-built): W1 #1735 #1736 #1737 #1738; W2 #1741 (the auto-derived gateway) #1739 #1740; W3 #1742 #1744 #1743; dispatch #1763; follow-up #1748
    • ADR-0024, ADR-0025, ADR-0026 — later ADRs that cite this one

Provenance — why this ADR is backfilled

This decision was fully recorded and formally approved at the time — as GitHub issue #1732 ("ADR-0022: MCP access & credential scoping rework"), which carries the adr + approved labels, an explicit "Approved" comment from the founder (Ashik) on 2026-07-10, and a PjM delivery sign-off on 2026-07-11. The entire model then shipped across ~15 merged PRs (2026-07-07 → 2026-07-13). What never happened was the mirroring of that approved issue into a canonical docs/adrs/ markdown file.

The number 0022 was deliberately reserved: ADR-0023 skips it with a footnote — "'ADR-0022' is reserved for the MCP registry/gateway/credential track … referenced 17× across docs/api/{mcpserver,toolpolicy,agent}.md … with no ADR-0022 file landed yet." The gap became load-bearing when three later ADRs — ADR-0024, ADR-0025, ADR-0026 — each cited "ADR-0022 (team gateway)" in their Refs, leaving every reader who chases the citation at a dead link in a repo whose tenet is "source control is the audit trail." This file closes that gap by faithfully mirroring the approved #1732 and grounding it in the shipped internal/gateway/mcpproxy implementation. Rationale, alternatives, and consequences below are recorded (from #1732 + code) except where explicitly marked reconstructed.


Context

ADR-0021 (#1682, accepted 2026-07-06) shipped the first governed-MCP access model: a server registry (mcp_servers), per-team bindings (mcp_server_units, migration 126), two-tier approval (T1 server-level, T2 binding-level), manually-curated group proxies (mcp_proxy_groups / /mcp/group/{id}, migration 131), per-tool guardrails (mcp_tool_policies, migration 132), and an upstream-credential write surface (internal/mcpserver/credential, migration 140).

In use, four structural gaps surfaced (recorded in #1732):

  1. Credential is org-global, not team/agent-scoped. credential/service.go hardcoded tenantTeamID = "_default", so a secret set at registration was shared by every team and every agent — the opposite of least-privilege. A single leaked upstream token had org-wide blast radius, even though the vault already had an unused team dimension.
  2. No per-agent upstream identity. Every agent presented the same shared token; there was no way to say "agent A gets its own bearer to this MCP, agent B falls back to the team token, agent C without one is denied." Migration 140's CHECK already admitted per-agent-suffixed keys — the storage was ready, the model was not.
  3. Guardrail scope stops at team. mcp_tool_policies was keyed (unit_id, server_id, tool_name) with unit_id a team unit or NULL (org-wide) — no agent dimension, so an agent-specific tool restriction was unexpressible.
  4. The gateway endpoint was a manual, drift-prone curation step. ADR-0021's aggregated surface (/mcp/group/{id}) required an operator to hand-curate group membership, which drifts from the approved-binding source of truth the resolver already computes.

The founder set the target model in a review discussion on 2026-07-10. This ADR captures that direction; it is a scoping rework of ADR-0021, not a redesign.


Decision

Five decisions were founder-directed and accepted (#1732, D1–D5). D5 — the auto-derived per-team gateway — is the flagship surface this file is named for and the one later ADRs cite. The four "proposed" items (P1–P4) and one flagged defect were resolved by founder ruling in the same 2026-07-10 review discussion and formally approved that day.

D1 — Registry = server + auth mode + team attachment only; no secret at registration. Registration captures the server, an auth mode (none | bearer | oauth2; XAA reserved for per-agent ID exchange), and its team attachment(s). The org-level _default credential concept is removed — the hardcoded _default team in credential/service.go is retired.

D2 — Credentials move to two scoped homes, both keyed by the vault's team dimension. A team-shared gateway credential per (server, team) binding at GetKey(org, team_unit_id, mcp_<type>_<registry_id>), and a per-agent credential per (server, agent) under a per-agent-suffixed provider key (migration 140's CHECK already permits it).

D3 — Per-agent vs team-shared is chosen PER BINDING. mcp_server_units gains credential_mode ∈ {team_shared, per_agent} plus optional require_per_agent (fail-closed: an agent with no own credential is denied, no fallback to the team token). The same server may be per_agent for team X and team_shared for team Y — the mode lives on the binding row, not the server. Resolution is most-specific-wins:

per_agent : agent cred → (team cred, unless require_per_agent) → deny
team_shared : team cred → org fallback (per auth_mode)

D4 — Guardrails gain an agent level. Guardrails set at registry (org) level, overridable at team and now agent level. Cascade semantics: more-specific-wins, EXCEPT an org-level hard DENY is an un-softenable floor — a child (team or agent) may only tighten, never soften, an ancestor deny (preserving the resolver invariant that orders (action='deny') DESC before specificity).

D5 — Standalone gateway curation is replaced by an AUTO-DERIVED, one-endpoint-per-team gateway. The aggregated MCP surface for a team is derived at request time from that team's approved, visibility-resolvable bindings (the T1+T2 approved set), reusing the group aggregation + session-fan-out machinery but dropping manual membership curation. The team page gains a gateway subsection (identity-rung badge per backend MCP, live entitlement matrix, per-MCP team-level guardrail editor). (As-built: this became a new /mcp/team/{unit_id} route alongside the retained /mcp/group/{id} curated route — see "As-built divergences" below.)

Resolved proposed decisions (founder-ruled 2026-07-10)

  • P1 → credential_mode + require_per_agent are columns on mcp_server_units (the binding is exactly the (server, team) grain the mode describes), not a separate policy table. Extract later only if it grows.
  • P2 → deny-floor is un-softenable. A child scope may tighten freely; softening an ancestor DENY is not a silent more-specific override. (Recorded as the invariant behind D4.)
  • P3 → agent-policy scope = a nullable agent_id column on mcp_tool_policies (option (a)), adding one precedence tier: agent-scoped > team-scoped, org deny-floor unchanged. Option (b) "agents-as-org_units" (which would let the existing ancestor_path cascade cover agents for free) was explicitly deferred to a future standalone ADR; option (a)'s rows map 1:1 onto agent-units if ever migrated.
  • P4 → per-agent MCP allowlist is narrow-only. Agent creation / the agent page can only narrow the team's approved set (default = inherit all; deselect only). Effective set = team resolved set ∩ agent allowlist ∩ agent guardrails. Grants stay exclusively team-level — the wizard is never a granting surface. The legacy RegisterAgent.tools array (bypassed by the Org v2.4 resolver) is repurposed into this allowlist.

Resolved flagged defect: visibility → HONOR

mcp_server_units.visibility was enforced for the tool axis but ignored by the MCP resolver (MCPServersForUnits filtered only on approval status), so a private binding still resolved to descendants. Ruling: HONOR — add the visibility predicate + sibling pass to the MCP reduction, matching the tool axis, with the sequencing guard that the read path must honor visibility before any write surface can set a non-inherit_down value, plus a deploy-time assertion counting non-inherit_down bindings (0 in all envs, verified 2026-07-10).


The auto-derived per-team gateway (D5) in detail — grounded in code

The flagship surface. Implemented in W2 (#1741, PR #1750, commit affe58d, 2026-07-10), with tools/call dispatch completed in #1763 (PR #1771, 2026-07-11).

Endpoint. A new coarse edge route /mcp/team/{unit_id} (TeamResourcePathPrefix, internal/gateway/mcpproxy/team.go) with its own RFC 9728 protected-resource metadata at /.well-known/oauth-protected-resource/mcp/team/{unit_id}. Prefix is strictly longer than /mcp/group/ and /mcp/, so Go's ServeMux longest-prefix routing sends team traffic here.

Virtual, derived membership (the core idea). There is no membership table and no sync-on-approve. PgTeamResolver (pg_team_resolver.go) derives the member set at request time from orgunit.Store.EffectiveMCPServerIDsForUnit — the same #1735 visibility-aware reduction (direct ∪ ancestor:inherit_down ∪ sibling:shared_siblings) the agent runtime uses to decide which MCP servers a team may call. Approve or revoke a binding and the team gateway reflects it on the next request. The resolver only hydrates the resulting server ids into Member records (name / transport / url / tool surface); it never re-decides eligibility.

Machinery reuse. The team gateway satisfies the same GroupResolver interface as a curated group and reuses the ADR-0021 T4/T5 group machinery wholesale: always-on {server}__{tool} prefixing (Aggregate), the reverse-routing lookup for dispatch (Aggregation.Resolve), the streamable-HTTP transport gate (EligibleTransport), and the Redis 1→N session-map fan-out (GroupSessionInitializer). Only the membership source differs (derived vs curated).

Self-heal gating contract (the key design contrast with a curated group). Because the surface is derived, a member it cannot safely aggregate is excluded, never a reason to fail the whole gateway — the opposite of a manual group, which is rejected on drift so an operator fixes it:

ConditionAuto-derived team (/mcp/team/{unit})Curated group (/mcp/group/{id})
unknown id404404
zero eligible members403403
member with callback-requiring transportEXCLUDED (gateway still serves the rest)403 (whole group rejected)
raw tool-name collision across membersPREFIXED — safe by construction, never 409409 (operator must fix)
eligible members present401 RFC 9728 challenge401 RFC 9728 challenge

Credential grain = the unit. A tools/call dispatched through the team gateway mints the member's upstream credential most-specific-wins against the team's unit scope (agent → team(unit) → org _default, #1742 / W3), honoring the binding's credential_mode / require_per_agent.

Tenant-scoping posture. The /mcp/team/{unit_id} route is cross-tenant at the edge (the unit id is a global UUID; OAuth-derived tenant context is ADR-0021 T7). PgTeamResolver looks up the unit's owning org first, then runs the RLS-bound reduction on a read-only tx whose app.org_id GUC is set to that org. In production this must run on the reconciler/BYPASSRLS connection T7 wires; in dev/compose the pool connects as superuser so the path is exercised end-to-end today. Gating logic is unit-tested through the GroupResolver interface with a fake.


As-built — divergences from the recorded decision

The shipped implementation matches #1732 faithfully on D1–D4 and P1–P4. D5 diverged in surface shape, which this record captures for future readers (grounded in cmd/context-engine/main.go:1762-1764 and internal/gateway/mcpproxy/{team,group}.go):

  • Recorded intent (#1732 D5 + migration note 5): "Standalone Gateway tab is REMOVED … Manual group curation is dropped"; "No mcp_proxy_groups schema change — only the membership source flips from manual to auto-derived." This reads as auto-derivation replacing curation on the existing group endpoint.
  • As shipped: a new, dedicated /mcp/team/{unit_id} route was added alongside the retained /mcp/group/{id} curated route. All three routes are mounted together — RegisterPublicRoutes (single-server /mcp/{id}), RegisterGroupPublicRoutes (curated group), RegisterTeamPublicRoutes (auto-derived team) at main.go:1762-1764. The manual group-CRUD RPCs (#1708/#1710) still exist. The auto-derived team gateway reuses the group machinery rather than superseding the group endpoint.
  • Why (reconstructed — the code does not state the motive): a separate route keeps the two gating contracts cleanly distinct (self-heal-by-exclusion for the derived surface vs reject-on-drift for the curated one), lets the team resolver plug into the identical machinery via the GroupResolver interface with zero curated-group regression, and avoids overloading one endpoint with two membership semantics. This is inferred from the code structure, not a recorded rationale.

Wave → issue → code map (all merged):

Wave / partIssue(s)What shippedKey files
W1 — honor visibility in MCP resolution#1735 (PR #1747)visibility predicate + sibling pass in MCPServersForUnitsinternal/orgunit resolver
W1 — agent-scoped tool policies#1736 (PR #1749)nullable agent_id + agent>team>org tiermigration 142, internal/guardrails/store.go
W1 — grant_type in upstream auth#1737 (PR #1745)client_credentials | token_exchangeinternal/auth/tokenexchange
W1 — team entitlement matrix RPC#1738 (PR #1746)team × server × tool verdictsinternal/mcpserver
W2 — auto-derived per-team gateway#1741 (PR #1750)/mcp/team/{unit} derived from approved bindingsteam.go, pg_team_resolver.go
W2 — binding write surface#1739 (PR #1752)credential_mode / visibility / require_per_agent + UpdateMCPServerBindingmigration 143, internal/mcpserver/binding.go
W2 — scoped credential writes#1740 (PR #1751)SetMCPServerCredential optional unit/agent scope + IDOR guard + offboard cascadeinternal/mcpserver/credential
W3 — most-specific-wins credential resolution#1742 (PR #1754)agent → team → org _default resolution honoring credential_modeinternal/mcpserver/credential/resolve.go, internal/auth/oauth/terminator.go
W3 — per-agent MCP allowlist (narrow-only)#1744 (PR #1753)SetAgentTools narrow-only intersection in freezeResolvedToolsinternal/agent, internal/agent/mcp_allowlist.go
W3 — backfill _default → team creds#1743 (PR #1756)one-time secret-copy migration, _default retiredmigration
dispatch#1763 (PR #1771)tools/call dispatch through the aggregated (team + group) endpointsdispatch.go
follow-up#1748multi-homed direct-ancestor binding demoted to inherit_down-gated in visibility passesresolver

A companion, continuously-verified delta map lives at docs/architecture/gateway-wip.md ("Gateway — Per-Agent Binding & Delta Map"); the full end-to-end system doc is docs/architecture/governed-mcp-and-the-team-gateway.md.


Alternatives considered

Recorded in #1732 (and, for P3, weighed against the resolver code):

  • Keep the org-global _default credential and add per-agent on top. Rejected — leaves org-wide blast radius in place and creates two competing credential grains.
  • Retain manual gateway group curation. Rejected by D5 — it drifts from the approved-binding source of truth the resolver already computes.
  • P3(b) — model each agent as a leaf org_unit. Attractive for resolver uniformity (the existing ancestor_path cascade and MCPServersForUnits reduction would cover agents for free), but a material org-model change with migration + resolver-fork churn. Deferred to a future standalone ADR; the chosen agent_id column maps 1:1 onto agent-units if that day comes.
  • Flip /mcp/group/{id} in place vs add /mcp/team/{unit}. The recorded decision implied flipping the membership source of the existing group endpoint; the implementation instead added a dedicated route (see As-built divergences). Both keep migration-131 tables unchanged.

Consequences

Positive (recorded in #1732)

  • Least-privilege by construction: team- and agent-scoped credentials replace one org-global token; a leaked-token blast radius drops from org → single team/agent.
  • Per-agent upstream identity becomes expressible end-to-end (storage was already ready via migration 140).
  • The team gateway auto-derives from approved bindings — a manual, drift-prone curation step is removed; approve/revoke is reflected without a sync.
  • Guardrails reach agent granularity, closing the ADR-0021 gap.

Negative / cost

  • New secret surface per team and per agent = more credential-write endpoints, audit volume, and rotation surface. Mitigated: the credential-scope extension is one generalized endpoint (SetMCPServerCredential gains optional unit_id / agent_id), not new RPCs, and it must validate scope ownership (IDOR guard) and cascade vault-entry deletion on agent offboard / binding removal.
  • Honoring visibility changes resolution output for any pre-existing non-inherit_down binding (near-zero rows expected given migration 126 backfilled inherit_down; guarded by the deploy-time assertion).
  • grant_type addition is default-preserving: absent/empty ⇒ current RFC 8693 token-exchange behavior, byte-identical requests; existing goldens unchanged, new goldens only for client_credentials.
  • The _default → team credential backfill copies secrets (never a schema drop) and must run with the same never-log discipline; it required founder sign-off for secret handling.

Cross-check of the citing ADRs

Verified each later ADR's characterization of "ADR-0022" resolves correctly against this record + the code. No factual errors were found; no edits to the citing ADRs are needed. Two notes on precision (recorded here, not corrected in-place):

  • ADR-0024 — "ADR-0021/0022 (2026-07-06 / 07-10) then re-implemented tool authorization at the team gateway edge (internal/gateway/mcpproxy/dispatch.go) as the canonical surface"; "approved binding + Manager approval, ADR-0021 D8 / ADR-0022." Accurate. The 07-10 date, the team-gateway edge as the authorization surface, and the D8-approval/0022-gateway split all match this record and the code (dispatch.go, #1763).
  • ADR-0025 — "ADR-0022 (team gateway)"; the egress defects it closes (initialize fan-out + tools/call Forward, Member.OrgID added for the guarded init) are exactly on the team-gateway path. Accurate.
  • ADR-0026 — "ADR-0022 (auto-derived team gateway)"; and separately "the team MCP gateway (/mcp/team/{unit}, /mcp/group/{id}) … all live in context-engine," with the supersession attributed jointly to "ADR-0021/0022." Substantively accurate but slightly imprecise: /mcp/group/{id} is the curated group (ADR-0021 T4/T5), and only /mcp/team/{unit} is the auto-derived team gateway (this ADR, W2). ADR-0026 lumps them under "the team MCP gateway." Both are aggregated MCP surfaces co-located in context-engine and the joint "ADR-0021/0022" attribution is correct (group ← 0021, team ← 0022), so this is a naming shorthand, not a factual error — flagged, not edited.

Recorded vs reconstructed

  • Recorded (from approved issue #1732, its founder-approval + PjM sign-off comments, the merged PRs, and the shipped code): the context/problem statement, D1–D5, the resolved P1–P4 and the visibility HONOR ruling, the auto-derived gateway mechanism and self-heal gating contract, the alternatives, the consequences, and all dates/owners.
  • Reconstructed (inference, marked inline above): only the motive for shipping a dedicated /mcp/team/{unit} route rather than flipping /mcp/group/{id} in place — the code shows the outcome but states no rationale, so the "why" is inferred from the code structure and the distinct gating contracts. Everything else is grounded in the record.