Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
118 commits
Select commit Hold shift + click to select a range
b387747
ci: apply middleware/migrations in the schema job
Weegy Jul 30, 2026
9ac4c37
test(mcp): first pg coverage for the MCP registry and OAuth schema
Weegy Jul 30, 2026
1704d4f
feat(mcp): preserve structuredContent via an out-of-band sidecar and …
Weegy Jul 30, 2026
a6a7db7
Merge remote-tracking branch 'origin/main' into ci/w0-4-migration-dom…
Weegy Jul 30, 2026
4aad5c1
Merge remote-tracking branch 'origin/main' into feat/w1-3-mcp-structu…
Weegy Jul 30, 2026
4ece874
fix(mcp-oauth): validate RFC 9207 iss, make delegation explicit, sing…
Weegy Jul 30, 2026
d3f5598
Merge remote-tracking branch 'origin/main' into fix/w0-1-mcp-oauth-is…
Weegy Jul 30, 2026
2575db6
feat(orchestrator): race every tool dispatch against a per-tool deadline
Weegy Jul 30, 2026
20c3b99
fix(mcp): state the callTool request policy and stop retrying Unautho…
Weegy Jul 30, 2026
5f24a5f
test(orchestrator): prove the dispatch deadline discards late results
Weegy Jul 30, 2026
fdd2936
test(mcp): isolate the re-apply check in a schema, not a database
Weegy Jul 30, 2026
904b17c
perf(orchestrator): deterministic tool ordering + stateless loopback …
Weegy Jul 30, 2026
6516f7e
test(mcp): first real McpManager round-trip against a live MCP server
Weegy Jul 30, 2026
bca24a0
Merge remote-tracking branch 'origin/main' into fix/w0-2-dispatch-dea…
Weegy Jul 30, 2026
3b87ffe
feat(mcp): mark the legacy HTTP+SSE transport deprecated (MCP 2026-07…
Weegy Jul 30, 2026
6090fcb
feat(web-ui): gate the deprecated sse transport behind an operator to…
Weegy Jul 30, 2026
8c91aa0
test(mcp): cover the sse deprecation flag, catalog preference, and re…
Weegy Jul 30, 2026
6d79e25
test(web-ui): cover the deprecated-transport toggle and badge; changelog
Weegy Jul 30, 2026
6ecbc0a
Merge remote-tracking branch 'origin/fix/w0-1-mcp-oauth-iss-delegatio…
Weegy Jul 30, 2026
e889fe5
Merge remote-tracking branch 'origin/feat/w1-1-deprecate-sse-transpor…
Weegy Jul 30, 2026
75cc579
Merge remote-tracking branch 'origin/feat/w1-3-mcp-structured-content…
Weegy Jul 30, 2026
39108d7
Merge remote-tracking branch 'origin/perf/w0-3-deterministic-tool-ord…
Weegy Jul 30, 2026
f93fe9d
Merge remote-tracking branch 'origin/fix/w0-2-dispatch-deadline-and-m…
Weegy Jul 30, 2026
f4e71eb
refactor(tasks): extract a generic long-running task seam
Weegy Jul 30, 2026
e0b6eb9
feat(mcp): add CIMD as a third client-acquisition mode alongside manual
Weegy Jul 30, 2026
3b53946
refactor(dev-platform): make dev_job the task seam's first implementor
Weegy Jul 30, 2026
a0c7eae
feat(mcp): park input_required tool calls (MRTR, #544)
Weegy Jul 30, 2026
82f356a
test(mcp): cover the CIMD acquisition chain, metadata endpoint, SSRF …
Weegy Jul 30, 2026
f18428d
test(tasks): pin the seam's claim/lease, non-blocking, card and priva…
Weegy Jul 30, 2026
977b73f
fix(test): pin the pg tenant schema as a connection option, not a SET…
Weegy Jul 30, 2026
d7d0ede
feat(agents): wire deferred sub-agent dispatch through the task seam
Weegy Jul 30, 2026
251ce8f
feat(web-ui): surface the CIMD acquisition mode and keep the manual p…
Weegy Jul 30, 2026
91ea744
docs(mcp): record the CIMD decision, deployment requirement, and chan…
Weegy Jul 30, 2026
dfe30f4
test(dev-platform): seam-conformance suite for dev_job on real Postgres
Weegy Jul 30, 2026
22bd8f4
feat(web-ui): generic long-running task card + i18n, and changelog
Weegy Jul 30, 2026
cebf71e
test(mcp): mutation-verified coverage for the input_required park (#544)
Weegy Jul 30, 2026
8c22e6d
fix(test): stop the dev_job orphan sweep from polluting sibling pg su…
Weegy Jul 30, 2026
1016e02
Merge remote-tracking branch 'origin/main' into feat/mcp-2026-07-28-w…
Weegy Jul 30, 2026
cb77750
Merge remote-tracking branch 'origin/feat/w2-4-mcp-oauth-cimd' into f…
Weegy Jul 30, 2026
43cc8bd
Merge remote-tracking branch 'origin/refactor/w2-2-generalize-long-ru…
Weegy Jul 30, 2026
4380b28
feat(orchestrator): short-circuit the turn on a pending MCP input req…
Weegy Jul 30, 2026
601c7de
feat(mcp): wire the pending-input store and replayer into the kernel …
Weegy Jul 30, 2026
df451c7
fix(mcp): bind the parked-record owner at claim time, not at park time
Weegy Jul 30, 2026
78c86f5
test(orchestrator): cover the buffered path and close both mutation s…
Weegy Jul 30, 2026
014ecbe
feat(web-ui): MCP mid-call input form, i18n, changelog (#544)
Weegy Jul 30, 2026
f9936fd
Merge remote-tracking branch 'origin/feat/w2-1-mcp-mrtr-input-require…
Weegy Jul 30, 2026
1d6c2f7
feat(orchestrator): idempotency primitive for write-capable tool disp…
Weegy Jul 30, 2026
282b446
feat(orchestrator): close the privacy/trace seam in ToolDispatchServi…
Weegy Jul 30, 2026
7922603
test(orchestrator): mutation-checked privacy masking on the dispatch …
Weegy Jul 30, 2026
4b26167
fix(orchestrator): propagate turnContext into the streaming tool loop
Weegy Jul 30, 2026
1a352b9
test(orchestrator): mutation-checked exactly-once semantics for write…
Weegy Jul 30, 2026
cb2aa1b
Merge remote-tracking branch 'origin/main' into feat/mcp-2026-07-28-w…
Weegy Jul 30, 2026
7be3443
fix(orchestrator): make the tool-dispatch and MCP timeout bounds cohe…
Weegy Jul 30, 2026
98e9e01
test(embeddings): stop the gate-fence fixture from reaching into public
Weegy Jul 30, 2026
9cb374f
feat(mcp): add mcp:list / mcp:invoke / mcp:write:<tool> scopes and th…
Weegy Jul 30, 2026
9b29790
fix(orchestrator): remove a literal NUL byte and disambiguate the ide…
Weegy Jul 30, 2026
23f9667
feat(plugin-api): expose writeCapabilities on the plugin-facing tool …
Weegy Jul 30, 2026
c11778f
feat(mcp): PublicMcpServer, per-key binding store and the /api/v1/mcp…
Weegy Jul 30, 2026
3445dbb
feat(devplatform): make the stalled-job sweep narrowable, and restore…
Weegy Jul 30, 2026
3d3335c
Merge remote-tracking branch 'origin/feat/mcp-2026-07-28-wave0-wave1'…
Weegy Jul 30, 2026
ec04f9a
feat(mcp): mount /api/v1/mcp behind publicPaths + requireApiKey, dark…
Weegy Jul 30, 2026
e1a5647
test(mcp): 102 tests for the public MCP endpoint against the real mid…
Weegy Jul 30, 2026
88f7d52
Merge remote-tracking branch 'origin/feat/w3-b-dispatch-privacy-seam-…
Weegy Jul 30, 2026
1b83db5
Merge remote-tracking branch 'origin/feat/mcp-2026-07-28-wave0-wave1'…
Weegy Jul 30, 2026
c8284f8
test(mcp): mutation harness for the public MCP endpoint
Weegy Jul 30, 2026
b786a72
Merge branch 'feat/mcp-2026-07-28-wave0-wave1' of github.com:byte5ai/…
Weegy Jul 30, 2026
65fe5db
docs(mcp): correct the pending-input rationale that cited the turnCon…
Weegy Jul 30, 2026
1d6eef2
feat(mcp): consume the landed privacy seam and fail CLOSED for public…
Weegy Jul 30, 2026
84d73ab
docs(mcp): external-consumer README, CHANGELOG entry, and acting-iden…
Weegy Jul 30, 2026
9836d0c
fix(mcp): restore the per-tool timeout, leaked as disabled by the mut…
Weegy Jul 30, 2026
0dbff03
test(mcp): correct three mutations that were passing for the wrong re…
Weegy Jul 30, 2026
082ce05
style(mcp): drop the now-unused ApiKeyScope import
Weegy Jul 30, 2026
6722866
Merge remote-tracking branch 'origin/feat/w2-3-public-mcp-endpoint' i…
Weegy Jul 30, 2026
ceea436
fix(tasks): stop stranding claims, losing outcomes and reaping human-…
Weegy Jul 30, 2026
b843c4f
fix(mcp,orchestrator): count the retry in the timeout invariant and e…
Weegy Jul 30, 2026
fbebc22
fix(orchestrator): a failing turn teardown must not replace the exit …
Weegy Jul 30, 2026
012121a
fix(orchestrator): bound in-flight idempotency entries instead of pin…
Weegy Jul 30, 2026
59c8432
test(tasks,mcp): pin the claim hint, the sweep re-entrancy guard and …
Weegy Jul 30, 2026
8852ce7
fix(mcp): mask tool ERROR text at the same boundary as tool results
Weegy Jul 30, 2026
c22420d
fix(mcp): enforce `masked()` — a result that skipped the boundary is …
Weegy Jul 30, 2026
9f64674
fix(mcp): narrow migration 0031's delegation backfill to operator tok…
Weegy Jul 30, 2026
96f98a1
fix(mcp): make tools/list and tools/call use one predicate
Weegy Jul 30, 2026
eddace5
test(mcp): pin the non-string-handler branch the `masked()` assertion…
Weegy Jul 30, 2026
d20d993
refactor(tasks): describe the task seam by contract, not by its first…
Weegy Jul 31, 2026
3fbdd6f
chore(470): raise decoupling baseline to 3441 for the W2-2 task seam
Weegy Jul 31, 2026
ed4cebf
chore(470): re-base decoupling baseline onto main (#552, #553)
Weegy Jul 31, 2026
e780ab5
fix(mcp): anchor 0031 constraint guard to the relation and drop the h…
Weegy Jul 31, 2026
deef5cc
fix(mcp): produce the mcpUserKey the per_user delegation path consume…
Weegy Jul 31, 2026
4875175
feat(mcp): operator surface for public MCP key bindings (W5-1, server…
Weegy Jul 31, 2026
8fc9dd0
feat(web-ui): client fns + types for public MCP key bindings (W5-1)
Weegy Jul 31, 2026
07f88fe
test(mcp): cover the W4-1 mcpUserKey producer on both chat and channe…
Weegy Jul 31, 2026
8254757
test(middleware): bound the suite with --test-timeout=120000 (W4-3)
Weegy Jul 31, 2026
ebeba90
test(mcp): cover interleaved channel turns from different users (W4-1)
Weegy Jul 31, 2026
827f00d
feat(web-ui): Public API keys tab in the MCP Control Center (W5-1)
Weegy Jul 31, 2026
b7e55e4
test(mcp): close throwaway servers in a finally so a failed assertion…
Weegy Jul 31, 2026
ccdbb1e
merge(mcp): public MCP key-binding admin surface (W5-1)
Weegy Jul 31, 2026
ac0186d
test(mcp): prove #547 structured sidecar bypasses the privacy boundar…
Weegy Jul 31, 2026
b698891
test(mcp): import the #547 privacy proof from source, not the dist ba…
Weegy Jul 31, 2026
1d1ba38
merge(mcp): pin the #547 structured-content privacy bypass (W5-2)
Weegy Jul 31, 2026
3c5c53c
docs(changelog): record the wave 4-6 fixes and the #547 privacy limit
Weegy Jul 31, 2026
03ce967
chore: escape raw NUL bytes as \0 in composite map-key separators
Weegy Jul 31, 2026
9b21fa3
merge(hygiene): escape the raw NUL bytes that made ripgrep truncate e…
Weegy Jul 31, 2026
652ae11
fix(memory): the NUL guard blamed a space, and had no coverage at all
Weegy Jul 31, 2026
a502db3
fix(web-ui): raise the vitest timeout above the honest cost of the RT…
Weegy Jul 31, 2026
f628625
chore(470): correct the decoupling baseline that raw NUL bytes had un…
Weegy Jul 31, 2026
a290566
fix(mcp): close three findings from the cross-vendor review of W4-1
Weegy Jul 31, 2026
81addfc
docs(changelog): correct the #547 privacy framing and record the repl…
Weegy Jul 31, 2026
d68245d
fix(mcp): an omitted field must never widen a public MCP key binding
Weegy Jul 31, 2026
e811198
docs(changelog): record the W5-1 review fixes as operator-visible beh…
Weegy Jul 31, 2026
8b18983
merge(mcp): close the cross-vendor review findings on the binding adm…
Weegy Jul 31, 2026
04ea85c
fix(privacy): intern MCP input-replay results before they reach the L…
Weegy Jul 31, 2026
88cc087
docs(test): name the sidecar mechanism instead of a verdict in #547 c…
Weegy Jul 31, 2026
942fc0a
merge(mcp): intern the replay result before it reaches the LLM wire
Weegy Jul 31, 2026
8e01c1f
merge(main): pick up #558 (macOS installer signing)
Weegy Jul 31, 2026
b715f14
test(memory): widen the path-validation coverage the review found too…
Weegy Jul 31, 2026
ee814f7
fix(migrations): run the 0031 delegation backfill only when the colum…
Weegy Jul 31, 2026
68b04e0
merge(mcp): gate 0031's backfill on the column's introduction
Weegy Jul 31, 2026
4cb411d
merge(main): pick up #574 (macOS x64 installer) and #583 (competitive…
Weegy Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,18 @@ jobs:
# in lexical (file-name) order — which matches the numbered
# migration convention. Newline-separated so the shell loop can
# iterate cleanly.
#
# `middleware/migrations` is the core runtime domain — the shared
# schema for the main app's features. It is self-contained: no
# cross-domain FKs and no extension dependencies, so its position
# in this list is not load-bearing.
#
# Still uncovered (each needs its own audit before enabling):
# middleware/src/conductor/migrations,
# middleware/src/services/graph/migrations,
# middleware/packages/harness-memory-postgres/src/migrations.
MIGRATION_DOMAINS: |
middleware/migrations
middleware/packages/harness-knowledge-graph-neon/src/migrations
middleware/src/auth/migrations
middleware/src/plugins/routines/migrations
Expand Down
511 changes: 511 additions & 0 deletions docs/CHANGELOG.md

Large diffs are not rendered by default.

172 changes: 172 additions & 0 deletions docs/adr/0006-mcp-client-id-metadata-documents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# 0006 — Client ID Metadata Documents coexist permanently with manual OAuth clients

## Status

Accepted

- **Date:** 2026-07-30
- **Deciders:** omadia maintainers
- **Supersedes:** —

## Context and Problem Statement

omadia has shipped a provider-agnostic MCP OAuth 2.1 + PKCE stack since epic
#459 W9. To obtain the OAuth client it presents to an authorization server it
had two paths: RFC 7591 Dynamic Client Registration (DCR), and a client an
operator registers once by hand. The MCP authorization spec now deprecates DCR
in favour of **Client ID Metadata Documents (CIMD)**, where the `client_id` is
an https URL the authorization server *dereferences* to read `redirect_uris`
and `client_name`.

Issue #546 framed CIMD as "the enterprise answer" and implied the manual client
was a stopgap. Should omadia adopt CIMD as the new default and sunset the manual
path?

## Decision Drivers

- **The two real IdPs in enterprise deployments — Microsoft Entra ID and Okta —
do not support CIMD.** They use pre-registered app registrations. For them the
manual client is not a workaround; it is the protocol-correct path.
- **CIMD inverts the network direction.** Every other mode only requires omadia
to reach *out*: a redirect the operator's browser follows, an outbound token
POST. CIMD requires the identity provider to reach *in* and GET a URL on
omadia's own host. That is a strictly stronger requirement.
- Many omadia installs are on-premises or behind a corporate firewall and have
**no inbound HTTPS route at all**. A design that needs one cannot be the only
path.
- DCR's deprecation is on a 12-month clock, and brokers that only offer DCR must
keep working throughout.
- Single tenancy: **no table in any migration carries a tenant column.** One
omadia install serves one organization, so a CIMD `client_id` identifies the
whole install.

## Considered Options

- **A — CIMD as one link in an explicit chain; manual stays permanent.**
- **B — CIMD as the default, manual deprecated behind a compatibility flag.**
- **C — byte5 hosts a metadata relay so every install gets a working
`client_id` regardless of inbound reachability.**

## Decision Outcome

Chosen option: **A**.

The client-acquisition chain is explicit and ordered:

```
stored → cimd → dcr (deprecated, warns) → manual → McpOAuthNeedsClientError
```

CIMD is attempted **only** when both sides agree it can work: the authorization
server advertises `client_id_metadata_document_supported`, a metadata URL is
configured, and the document is verifiably reachable. Any of those failing makes
the chain fall through — never fail.

**Option C was rejected outright.** A byte5-hosted relay would mean every
customer's `client_id` is a URL on a byte5 domain, so every customer's OAuth
client would *identify byte5* to that customer's identity provider. That is
wrong on identity grounds before it is wrong on availability grounds, and it
inserts byte5 into a customer's authorization path. It is not offered by
default, and it is not planned.

**Option B was rejected** because it inverts which path is load-bearing: it
would deprecate the only mode Entra ID and Okta can use.

### Consequences

- 🟢 **Good:** CIMD removes the app-registration step at MCP-native brokers
(Smithery-class) without changing anything for IdP-backed servers.
- 🟢 **Good:** A firewalled install degrades cleanly. The metadata endpoint
answers **501 with an actionable message**, not 500, and the manual path keeps
working untouched.
- 🟢 **Good:** DCR keeps working and merely logs a deprecation notice, so no
broker breaks on our timeline.
- 🔴 **Bad:** Three acquisition modes is more surface than two. Mitigated by
making the chain a single ordered function and surfacing the resolved mode in
the UI, so an operator can always see which one applies and why.
- 🔴 **Bad:** Reachability cannot be *proven* from inside the process — only the
IdP's own network can answer it. We check the conditions that make the answer
definitively "no" (see below) and treat a "yes" as a strong necessary
condition, not a guarantee.
- ⚪ **Neutral:** `mcp_oauth_clients.registered_via` gains `'cimd'` and a
`client_metadata_url` column (migration `0032`).

## Deployment note

**CIMD requires inbound HTTPS reachability.** Set `FLOW_PUBLIC_BASE_URL` to an
https origin this deployment is reachable at **from the internet**. The identity
provider fetches `GET {FLOW_PUBLIC_BASE_URL}/.well-known/omadia-mcp-client`
itself; an origin that only resolves inside your network will not do.

- The metadata URL is derived from `FLOW_PUBLIC_BASE_URL` **alone** —
deliberately not the `?? PUBLIC_BASE_URL` fallback the redirect URI uses,
because `PUBLIC_BASE_URL` defaults to `http://localhost:3979`, exactly the
shape that is not inbound reachable. Requiring an explicit declaration means an
unconfigured install lands in the clean degraded state rather than publishing a
`client_id` no provider can fetch.
- It is derived from **config, never from the inbound `Host` header**, so it is
stable across restarts and proxy hops. The URL *is* the `client_id` and stored
`mcp_oauth_clients` rows reference it.
- The served `redirect_uris` must equal `McpOAuthService.redirectUri` exactly. If
they diverge, the authorization server matches the authorize request's
`redirect_uri` against the document, finds no match, and every code exchange
fails — at the provider, far from the cause. Both are wired from one variable
in `index.ts`, and `middleware/test/mcpOAuth.test.ts` asserts the equality.
- **If inbound access is not possible, nothing is broken.** Register a one-time
OAuth client per issuer in the MCP Control Center. That path is fully
supported, permanently, and is the correct path for Entra ID and Okta.
- **A byte5-hosted metadata relay is not offered by default** — see Option C
above.
- **Single tenancy is the reality.** No migration defines a tenant column. One
install serves one organization, `mcp_oauth_clients` is keyed by issuer alone,
and the CIMD `client_id` identifies the install as a whole. Do not read
multi-tenancy into this schema.

### Reachability check

The probe (`services/mcpCimd.ts`) reuses `assertPublicHttpsUrl` from
`services/ssrfGuard.ts` — the same guard the RFC 9728 / RFC 8414 discovery chain
uses, deliberately not a second validator. It rejects:

1. no configured public base origin;
2. plain http, and RFC 1918 / loopback / link-local / CGNAT literals,
`.internal` / `.local` names, and hostnames that DNS-resolve into those
ranges;
3. a URL that does not serve *our* document — the fetched document's `client_id`
must equal the URL fetched, so a catch-all proxy route answering `200` with
something else is caught.

The verdict is cached (5 min, single-flight) because it runs on every MCP Control
Center status poll.

## Security properties preserved

- A CIMD client is **public by construction**: the document is world-readable, so
there is no client secret and PKCE alone protects the exchange.
`token_endpoint_auth_method` is `"none"` — an accurate claim, not a shortcut.
- The metadata document carries **no secret**: only the redirect URI and a
display name, both of which the IdP already sees during the authorize
round-trip.
- Tokens stay in the vault namespace; `mcp_oauth_tokens` holds refs only.
- No token, `code`, or `code_verifier` reaches a log line — OAuth error text goes
through `services/secretRedaction.ts`.
- `mcp_oauth_flows` TTL is enforced in two places (verified, not assumed): an
opportunistic prune of rows older than 15 minutes on every flow create, and an
age-bounded `DELETE … RETURNING` on consume, so a leaked stale `state` cannot
be redeemed later even if no prune has run.
- The flow-bound endpoint pinning and the RFC 9207 `iss` validation added in W0-1
are untouched.

## More Information

- Issue #546 (CIMD half, W2-4). The issue body's premise that the registry
"supports only static headers with `secretRef`" is **incorrect** — the full
OAuth 2.1 + PKCE stack shipped in epic #459 W9.
- Implementation: `middleware/src/services/mcpCimd.ts`,
`middleware/src/services/mcpOAuthService.ts` (`ensureClient`),
`middleware/src/routes/mcpClientMetadata.ts`,
`middleware/src/auth/publicPaths.ts`,
`middleware/migrations/0032_mcp_oauth_cimd.sql`.
- Tests: `middleware/test/mcpOAuth.test.ts`,
`middleware/test/publicPaths.test.ts`,
`middleware/test/mcpOAuthCimdMigration.pg.test.ts`.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ decision, write a new ADR and mark the old one **Superseded by …**.
| 0003 | [Capability-based, multi-provider middleware](0003-capability-based-multi-provider-middleware.md) | Accepted | 2026-06-03 |
| 0004 | [Knowledge graph as the agent memory substrate](0004-knowledge-graph-as-memory-substrate.md) | Accepted | 2026-06-03 |
| 0005 | [Two-phase confirmation for write-capable connectors](0005-two-phase-confirmation-for-writes.md) | Accepted | 2026-06-03 |
| 0006 | [Client ID Metadata Documents coexist permanently with manual OAuth clients](0006-mcp-client-id-metadata-documents.md) | Accepted | 2026-07-30 |

> These first records are written *retroactively* — they document decisions that
> were already implemented and proven in the product. New decisions should be
Expand Down
145 changes: 145 additions & 0 deletions middleware/migrations/0031_mcp_oauth_iss_delegation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
-- ── MCP OAuth: RFC 9207 `iss` binding + explicit delegation mode (W0-1) ─────
-- Three live defects in the MCP OAuth path are closed here:
--
-- D1 The callback trusted `state` alone and never validated the RFC 9207
-- `iss` authorization-response parameter against the issuer recorded for
-- the flow. `mcp_oauth_flows.issuer` already exists; what was missing is
-- knowing whether the authorization server ADVERTISED iss support, so an
-- absent `iss` from an AS that promised one can be rejected. That flag is
-- captured at authorize time (never re-discovered at callback — same
-- reasoning as migration 0016).
--
-- D2 `oauthUserKey()` silently fell back to the shared literal 'operator',
-- so a channel turn (Teams/Telegram) with no mapped identity inherited
-- the operator's authority at the customer's MCP server — a confused
-- deputy. `mcp_servers.delegation` makes the choice explicit per server:
-- per_user → the acting identity must resolve, or the call fails closed
-- service → one shared identity is the deliberate, opted-in design
--
-- D3 Concurrent refreshes for the same (server, user) raced each other. Not
-- a schema concern, but `mcp_oauth_tokens.issuer` lands here so a stored
-- token can be invalidated when its issuer rotates.
--
-- ⚠️ OPERATOR-VISIBLE BEHAVIOUR CHANGE — read before deploying.
-- A fail-closed `per_user` default for EVERY row would break installed systems
-- whose channel users reach MCP servers today precisely BECAUSE of the
-- 'operator' fallback. So this migration is deliberately asymmetric:
-- • when `mcp_servers.delegation` is introduced, pre-existing rows that
-- already hold an operator token are grandfathered to today's shared
-- behaviour (`delegation = 'service'`),
-- • operators who later opt an existing server into `per_user` stay there on
-- every re-apply, because that grandfathering runs only on the first
-- application that adds the column, and
-- • only NEWLY created servers get the safe `per_user` default.
-- That one-time asymmetry is intentional. A new server that later acquires an
-- operator token is still `per_user` unless an operator deliberately chooses
-- shared delegation in the MCP Control Center (or UPDATEs the column directly).

-- ── D2: explicit delegation mode per MCP server ─────────────────────────────
-- The column introduction and the compatibility backfill are one decision and
-- must happen exactly once. Re-applying 0031 after an operator has opted an
-- existing server into `per_user` must preserve that operator decision:
-- changing `delegation` does NOT delete the stored operator token row, so a
-- standing backfill would silently flip the server back to `service` on the
-- next apply and reopen D2 for unmapped channel users.
--
-- Backward compatibility on that first apply only: every EXISTING server that
-- already holds an OPERATOR token keeps the shared identity it is working with
-- today. Guarded by `to_regclass` so the migration is safe on a database where
-- `mcp_oauth_tokens` has not been created yet. The argument is UNQUALIFIED on
-- purpose: it must resolve through `search_path` like every other reference in
-- this file. A hardcoded `public.` would probe the wrong schema wherever the
-- domain is applied outside `public` — the guard would then answer about a
-- table this statement does not touch, and the backfill would be skipped (or
-- run) for a reason unrelated to the data in front of it.
--
-- The predicate is `user_key = 'operator'`, NOT "has any token row". This
-- backfill exists solely to preserve the behaviour the 'operator' fallback was
-- producing (see D2 above), and that fallback only ever applied where an
-- operator token existed to borrow. A server holding only per-user tokens —
-- `user_key = 'alice@corp.com'` and nothing else — was never using a shared
-- identity, so flipping it to `service` would be a silent identity change no
-- operator decided on: `resolveMcpUserKey` would hand every caller the shared
-- `operator` key, and once anyone completed a re-auth the minted operator token
-- would be shared by every caller, including unmapped channel users. The
-- narrow predicate leaves such a server on the safe `per_user` default, which
-- is the choice its stored tokens already imply.
--
-- The literal must stay in sync with `SERVICE_USER_KEY` in
-- `src/services/mcpDelegation.ts` (a migration cannot import it).
DO $$
DECLARE
delegation_exists BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1
FROM pg_attribute
WHERE attrelid = 'mcp_servers'::regclass
AND attname = 'delegation'
AND NOT attisdropped
)
INTO delegation_exists;

IF NOT delegation_exists THEN
-- Plain ADD COLUMN, not IF NOT EXISTS: the catalog gate above already
-- proved absence, and masking a broken gate here would turn a logic error
-- into silent drift instead of failing loudly at the statement that broke.
ALTER TABLE mcp_servers
ADD COLUMN delegation TEXT NOT NULL DEFAULT 'per_user';

IF to_regclass('mcp_oauth_tokens') IS NOT NULL THEN
UPDATE mcp_servers s
SET delegation = 'service'
WHERE EXISTS (
SELECT 1 FROM mcp_oauth_tokens t
WHERE t.server_id = s.id
AND t.user_key = 'operator'
);
END IF;
END IF;
END $$;

-- `conname` is unique per (connamespace, conrelid), NOT cluster-wide, so an
-- unanchored lookup reports "exists" for a same-named constraint sitting in any
-- other schema and the ALTER below is silently skipped. Anchoring on
-- `conrelid = 'mcp_servers'::regclass` resolves the relation through
-- `search_path`, matching every other unqualified reference in this file. The
-- cast cannot raise here for any new reason: this migration still requires
-- `mcp_servers` to resolve, the gate block above already resolved the same
-- regclass while checking whether `delegation` exists, and the old `ALTER TABLE
-- ... ADD COLUMN IF NOT EXISTS` would likewise have failed on a missing table
-- because `IF NOT EXISTS` guards the column name, not the relation.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'mcp_servers_delegation_chk'
AND conrelid = 'mcp_servers'::regclass
) THEN
ALTER TABLE mcp_servers
ADD CONSTRAINT mcp_servers_delegation_chk
CHECK (delegation IN ('per_user', 'service'));
END IF;
END $$;

-- ── D1: remember whether the AS advertised RFC 9207 at authorize time ───────
-- NULL on pre-0031 in-flight flows → treated as "not advertised", so a flow
-- started before this migration is not retroactively rejected for a missing
-- `iss`. A mismatched `iss` is rejected regardless of this flag.
ALTER TABLE mcp_oauth_flows
ADD COLUMN IF NOT EXISTS iss_required BOOLEAN NOT NULL DEFAULT false;

-- ── D3 companion: bind a stored token to the issuer that minted it ──────────
-- Lets a token be invalidated when the server's issuer rotates instead of
-- being replayed against a different authorization server.
ALTER TABLE mcp_oauth_tokens
ADD COLUMN IF NOT EXISTS issuer TEXT;

-- ── Audit: record the acting identity on every MCP call ─────────────────────
-- `caller_agent` is the orchestrator/sub-agent slug, not WHO the call acted
-- as. Without this an operator cannot answer "whose credentials touched that
-- server?" — the exact question the confused-deputy bug raises.
ALTER TABLE mcp_call_log
ADD COLUMN IF NOT EXISTS acting_identity TEXT;

-- rollback: ALTER TABLE mcp_call_log DROP COLUMN acting_identity; ALTER TABLE mcp_oauth_tokens DROP COLUMN issuer; ALTER TABLE mcp_oauth_flows DROP COLUMN iss_required; ALTER TABLE mcp_servers DROP CONSTRAINT mcp_servers_delegation_chk, DROP COLUMN delegation;
Loading
Loading