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
- ADR issue (approved decision record):
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):
- Credential is org-global, not team/agent-scoped.
credential/service.gohardcodedtenantTeamID = "_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. - 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.
- Guardrail scope stops at team.
mcp_tool_policieswas keyed(unit_id, server_id, tool_name)withunit_ida team unit or NULL (org-wide) — no agent dimension, so an agent-specific tool restriction was unexpressible. - 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_agentare columns onmcp_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_idcolumn onmcp_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 existingancestor_pathcascade 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.toolsarray (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:
| Condition | Auto-derived team (/mcp/team/{unit}) | Curated group (/mcp/group/{id}) |
|---|---|---|
| unknown id | 404 | 404 |
| zero eligible members | 403 | 403 |
| member with callback-requiring transport | EXCLUDED (gateway still serves the rest) | 403 (whole group rejected) |
| raw tool-name collision across members | PREFIXED — safe by construction, never 409 | 409 (operator must fix) |
| eligible members present | 401 RFC 9728 challenge | 401 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 (
#1732D5 + migration note 5): "Standalone Gateway tab is REMOVED … Manual group curation is dropped"; "Nomcp_proxy_groupsschema 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) atmain.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
GroupResolverinterface 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 / part | Issue(s) | What shipped | Key files |
|---|---|---|---|
W1 — honor visibility in MCP resolution | #1735 (PR #1747) | visibility predicate + sibling pass in MCPServersForUnits | internal/orgunit resolver |
| W1 — agent-scoped tool policies | #1736 (PR #1749) | nullable agent_id + agent>team>org tier | migration 142, internal/guardrails/store.go |
W1 — grant_type in upstream auth | #1737 (PR #1745) | client_credentials | token_exchange | internal/auth/tokenexchange |
| W1 — team entitlement matrix RPC | #1738 (PR #1746) | team × server × tool verdicts | internal/mcpserver |
| W2 — auto-derived per-team gateway | #1741 (PR #1750) | /mcp/team/{unit} derived from approved bindings | team.go, pg_team_resolver.go |
| W2 — binding write surface | #1739 (PR #1752) | credential_mode / visibility / require_per_agent + UpdateMCPServerBinding | migration 143, internal/mcpserver/binding.go |
| W2 — scoped credential writes | #1740 (PR #1751) | SetMCPServerCredential optional unit/agent scope + IDOR guard + offboard cascade | internal/mcpserver/credential |
| W3 — most-specific-wins credential resolution | #1742 (PR #1754) | agent → team → org _default resolution honoring credential_mode | internal/mcpserver/credential/resolve.go, internal/auth/oauth/terminator.go |
| W3 — per-agent MCP allowlist (narrow-only) | #1744 (PR #1753) | SetAgentTools narrow-only intersection in freezeResolvedTools | internal/agent, internal/agent/mcp_allowlist.go |
W3 — backfill _default → team creds | #1743 (PR #1756) | one-time secret-copy migration, _default retired | migration |
| dispatch | #1763 (PR #1771) | tools/call dispatch through the aggregated (team + group) endpoints | dispatch.go |
| follow-up | #1748 | multi-homed direct-ancestor binding demoted to inherit_down-gated in visibility passes | resolver |
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
_defaultcredential 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 existingancestor_pathcascade andMCPServersForUnitsreduction would cover agents for free), but a material org-model change with migration + resolver-fork churn. Deferred to a future standalone ADR; the chosenagent_idcolumn 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 (
SetMCPServerCredentialgains optionalunit_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
visibilitychanges resolution output for any pre-existing non-inherit_downbinding (near-zero rows expected given migration 126 backfilledinherit_down; guarded by the deploy-time assertion). grant_typeaddition is default-preserving: absent/empty ⇒ current RFC 8693 token-exchange behavior, byte-identical requests; existing goldens unchanged, new goldens only forclient_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 (
initializefan-out +tools/callForward,Member.OrgIDadded 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.