From 7f0bfa171e7616ae26bfc84f4c776ac97cdca3e8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:51:58 -0700 Subject: [PATCH 1/6] Project enterprise-managed state onto connections and health results --- packages/core/api/src/connections/api.ts | 5 +++++ packages/core/api/src/handlers/connections.ts | 1 + packages/core/api/src/handlers/oauth.ts | 1 + packages/core/api/src/oauth/api.ts | 6 +++++ packages/core/sdk/src/connection.ts | 15 +++++++++++++ packages/core/sdk/src/executor.ts | 22 +++++++++++++++++++ packages/core/sdk/src/health-check.ts | 13 +++++++++++ packages/react/src/api/atoms.tsx | 3 +++ 8 files changed, 66 insertions(+) diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb3..6c40512bdc 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({ // Last persisted health-check verdict (written by every checkHealth run), // so the list can show alive/expired at a glance without probing. lastHealth: Schema.NullOr(HealthCheckResult), + // True when the connection was minted through MCP Enterprise-Managed + // Authorization: it mirrors organization policy and holds no durable local + // grant, so a console must not offer to delete it. Read-only — nothing on + // this surface can set it. + enterpriseManaged: Schema.Boolean, }); const ToolResponse = Schema.Struct({ diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index 9ae476a97f..de766b07e9 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({ oauthScope: c.oauthScope ?? null, missingOAuthScopes: c.missingOAuthScopes ?? [], lastHealth: c.lastHealth ?? null, + enterpriseManaged: c.enterpriseManaged ?? false, }); const toolToResponse = (t: Tool) => ({ diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 92c3e5ed72..898fafd07b 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({ oauthClientOwner: c.oauthClientOwner ?? null, oauthScope: c.oauthScope ?? null, missingOAuthScopes: c.missingOAuthScopes ?? [], + enterpriseManaged: c.enterpriseManaged ?? false, }); const startResultToResponse = (result: ConnectResult) => diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 96e76a26c1..a68d66d3d4 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({ oauthClientOwner: Schema.NullOr(Owner), oauthScope: Schema.NullOr(Schema.String), missingOAuthScopes: Schema.Array(Schema.String), + // True when this connect took the enterprise-managed branch (the ID-JAG + // chain) rather than the interactive one. A `start` against an `id_jag` + // client can still land here as `false` — the profile falls back when the + // server does not advertise it — so the caller reads the outcome, not its + // own intent. + enterpriseManaged: Schema.Boolean, }); // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts index 9009011774..d84ab17645 100644 --- a/packages/core/sdk/src/connection.ts +++ b/packages/core/sdk/src/connection.ts @@ -61,6 +61,21 @@ export interface Connection { * "has this expired?" at a glance in the connections list without probing. * Null/absent = never checked. */ readonly lastHealth?: HealthCheckResult | null; + /** True when this connection was minted through MCP Enterprise-Managed + * Authorization and renews itself from the enterprise identity assertion + * persisted alongside it (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`). + * + * A BOOLEAN, deliberately: the wiring behind it (which IdP registration, + * which audience) is a server concern, and a console needs exactly one fact + * from it — that this connection mirrors organization policy rather than a + * grant the user holds. That single fact changes what the UI may offer: + * there is no durable local grant to delete, so a local "remove" would be a + * lie, and revocation belongs at the identity provider. + * + * It is NOT derivable client-side from `oauthClient`: a client whose grant + * is `id_jag` still falls back to the interactive flow against a server that + * does not advertise the profile, and such a connection is ordinary. */ + readonly enterpriseManaged?: boolean; } /** Identify one connection — unique by (owner, integration, name). */ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index f1b9443477..a4a4717bd3 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -818,6 +818,22 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] : []; }; +/** Project a credential-resolution failure's ADMINISTRATOR verdict onto the + * health result, so the console reads "your organization declined this" as + * structure rather than parsing the sentence in `detail`. Empty for every + * ordinary failure — an expired grant, a dead refresh token — which keeps the + * blocked branch impossible to reach by accident. */ +export const healthAdministratorVerdict = (failure: { + readonly blockedByAdmin?: boolean; + readonly oauthErrorCode?: string; +}): { readonly blockedByAdmin?: true; readonly oauthErrorCode?: string } => + failure.blockedByAdmin === true + ? { + blockedByAdmin: true, + ...(failure.oauthErrorCode === undefined ? {} : { oauthErrorCode: failure.oauthErrorCode }), + } + : {}; + /** The definitive refresh rejection recorded on `provider_state`, or null. * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying * cannot change the verdict); cleared by the reconnect mint, which rewrites @@ -852,6 +868,10 @@ const rowToConnection = (row: ConnectionRow): Connection => { oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), + // Read from the SAME persisted state the renewal path follows, so the + // console and the credential lifecycle can never disagree about which + // connections are enterprise-managed. + enterpriseManaged: enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)) !== null, }; }; @@ -3455,6 +3475,7 @@ export const createExecutor = oauthScope: null, missingOAuthScopes: [], lastHealth: null, + // A pasted credential is never enterprise-managed: that state is + // only ever written by the ID-JAG connect path. + enterpriseManaged: false, }; return [optimistic, ...rows]; }), From fdf9c317af87d138742f5d9a32b50adc164d6a8a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:53:17 -0700 Subject: [PATCH 2/6] Expose the enterprise identity assertion grant in the OAuth app form --- .../src/components/oauth-client-form.test.ts | 54 +++++++++++++++ .../src/components/oauth-client-form.tsx | 66 +++++++++++++++++-- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index f85367ad54..e20a7e2dde 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -112,6 +112,60 @@ describe("canSubmitOAuthClientForm", () => { }), ).toBe(false); }); + + // MCP Enterprise-Managed Authorization (`id_jag`). The app registered here is + // the client's registration at the MCP server's Resource Authorization + // Server; discovery starts from the resource identifier, so that field — not + // the authorization URL — is what the grant cannot do without. + it("requires a resource URL for enterprise identity assertion clients", () => { + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + resource: null, + }), + ).toBe(false); + }); + + it("accepts an enterprise identity assertion client with no authorization URL", () => { + // There is no browser redirect in this profile: the client presents an + // identity assertion instead of walking the user through consent. + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(true); + }); + + it("accepts a public enterprise identity assertion client with no secret", () => { + // The ID-JAG itself is the proof of authorization at the Resource + // Authorization Server (draft §4.4), so a secret is not mandatory. + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + clientSecret: "", + authorizationUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(true); + }); + + it("still requires a token URL for enterprise identity assertion clients", () => { + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + tokenUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(false); + }); }); describe("oauthAppSetupFor", () => { diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 9d73c881d8..4990f29fb6 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -99,13 +99,25 @@ export const canSubmitOAuthClientForm = (input: { readonly clientSecret: string; readonly authorizationUrl: string; readonly tokenUrl: string; + /** RFC 9728 resource identifier of the protected resource. Required for the + * `id_jag` grant — it is what enterprise-managed discovery starts from — and + * ignored for the other two, where it is a discovery by-product. */ + readonly resource?: string | null; }): boolean => !input.submitting && input.name.trim().length > 0 && input.clientId.trim().length > 0 && - (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) && + // Only client credentials has no other way to authenticate. The + // authorization-code grant may be a public PKCE client, and an `id_jag` + // client may be public at its Resource Authorization Server too — the ID-JAG + // itself is the proof of authorization there (draft §4.4). + (input.grant !== "client_credentials" || input.clientSecret.trim().length > 0) && input.tokenUrl.trim().length > 0 && - (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0); + // No browser redirect exists for client credentials, and an enterprise- + // managed client never runs one: it presents an assertion instead of walking + // the user through consent. + (input.grant !== "authorization_code" || input.authorizationUrl.trim().length > 0) && + (input.grant !== "id_jag" || (input.resource ?? "").trim().length > 0); export function OAuthClientForm(props: { /** Human label for the integration this app backs (used in toasts + default name). */ @@ -217,6 +229,10 @@ export function OAuthClientForm(props: { // client id/secret + owner. const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0; const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown); + // The enterprise-managed grant needs a resource identifier that no prefill + // carries (discovery starts from it), so its endpoint panel never collapses — + // a required field must not hide behind an "Edit". + const endpointsCollapsible = endpointsKnown && grant !== "id_jag"; const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" }); const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" }); @@ -232,6 +248,7 @@ export function OAuthClientForm(props: { clientSecret, authorizationUrl, tokenUrl, + resource, }); // DCR is offered when the server advertises a registration endpoint AND we @@ -454,6 +471,16 @@ export function OAuthClientForm(props: { label: "Client credentials", hint: "App-to-app, no user", }, + { + // MCP Enterprise-Managed Authorization. The app registered here + // is the client's registration at the MCP server's Resource + // Authorization Server; the second registration (at the + // enterprise identity provider) is the organization's, and the + // server points at it separately. + value: "id_jag", + label: "Enterprise identity assertion", + hint: "Your identity provider authorizes, no per-server consent", + }, ] as const ).map((option) => (