Unified · Split
| @@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({ · 61 unmodified lines | ||
| 62 | 62 | // Last persisted health-check verdict (written by every checkHealth run), |
| 63 | 63 | // so the list can show alive/expired at a glance without probing. |
| 64 | 64 | lastHealth: Schema.NullOr(HealthCheckResult), |
| 65 | + // True when the connection was minted through MCP Enterprise-Managed | |
| 66 | + // Authorization: it mirrors organization policy and holds no durable local | |
| 67 | + // grant, so a console must not offer to delete it. Read-only — nothing on | |
| 68 | + // this surface can set it. | |
| 69 | + enterpriseManaged: Schema.Boolean, | |
| 65 | 70 | }); |
| 66 | 71 | |
| 67 | 72 | const ToolResponse = Schema.Struct({ |
| @@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({ · 29 unmodified lines | ||
| 30 | 30 | oauthScope: c.oauthScope ?? null, |
| 31 | 31 | missingOAuthScopes: c.missingOAuthScopes ?? [], |
| 32 | 32 | lastHealth: c.lastHealth ?? null, |
| 33 | + enterpriseManaged: c.enterpriseManaged ?? false, | |
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | 36 | const toolToResponse = (t: Tool) => ({ |
| @@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({ · 45 unmodified lines | ||
| 46 | 46 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 47 | 47 | oauthScope: c.oauthScope ?? null, |
| 48 | 48 | missingOAuthScopes: c.missingOAuthScopes ?? [], |
| 49 | + enterpriseManaged: c.enterpriseManaged ?? false, | |
| 49 | 50 | }); |
| 50 | 51 | |
| 51 | 52 | const startResultToResponse = (result: ConnectResult) => |
| @@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({ · 51 unmodified lines | ||
| 52 | 52 | oauthClientOwner: Schema.NullOr(Owner), |
| 53 | 53 | oauthScope: Schema.NullOr(Schema.String), |
| 54 | 54 | missingOAuthScopes: Schema.Array(Schema.String), |
| 55 | + // True when this connect took the enterprise-managed branch (the ID-JAG | |
| 56 | + // chain) rather than the interactive one. A `start` against an `id_jag` | |
| 57 | + // client can still land here as `false` — the profile falls back when the | |
| 58 | + // server does not advertise it — so the caller reads the outcome, not its | |
| 59 | + // own intent. | |
| 60 | + enterpriseManaged: Schema.Boolean, | |
| 55 | 61 | }); |
| 56 | 62 | |
| 57 | 63 | // --------------------------------------------------------------------------- |
| @@ -61,6 +61,21 @@ export interface Connection { · 60 unmodified lines | ||
| 61 | 61 | * "has this expired?" at a glance in the connections list without probing. |
| 62 | 62 | * Null/absent = never checked. */ |
| 63 | 63 | readonly lastHealth?: HealthCheckResult | null; |
| 64 | + /** True when this connection was minted through MCP Enterprise-Managed | |
| 65 | + * Authorization and renews itself from the enterprise identity assertion | |
| 66 | + * persisted alongside it (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`). | |
| 67 | + * | |
| 68 | + * A BOOLEAN, deliberately: the wiring behind it (which IdP registration, | |
| 69 | + * which audience) is a server concern, and a console needs exactly one fact | |
| 70 | + * from it — that this connection mirrors organization policy rather than a | |
| 71 | + * grant the user holds. That single fact changes what the UI may offer: | |
| 72 | + * there is no durable local grant to delete, so a local "remove" would be a | |
| 73 | + * lie, and revocation belongs at the identity provider. | |
| 74 | + * | |
| 75 | + * It is NOT derivable client-side from `oauthClient`: a client whose grant | |
| 76 | + * is `id_jag` still falls back to the interactive flow against a server that | |
| 77 | + * does not advertise the profile, and such a connection is ordinary. */ | |
| 78 | + readonly enterpriseManaged?: boolean; | |
| 64 | 79 | } |
| 65 | 80 | |
| 66 | 81 | /** Identify one connection — unique by (owner, integration, name). */ |
| @@ -818,6 +818,22 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] · 817 unmodified lines | ||
| 818 | 818 | : []; |
| 819 | 819 | }; |
| 820 | 820 | |
| 821 | +/** Project a credential-resolution failure's ADMINISTRATOR verdict onto the | |
| 822 | + * health result, so the console reads "your organization declined this" as | |
| 823 | + * structure rather than parsing the sentence in `detail`. Empty for every | |
| 824 | + * ordinary failure — an expired grant, a dead refresh token — which keeps the | |
| 825 | + * blocked branch impossible to reach by accident. */ | |
| 826 | +export const healthAdministratorVerdict = (failure: { | |
| 827 | + readonly blockedByAdmin?: boolean; | |
| 828 | + readonly oauthErrorCode?: string; | |
| 829 | +}): { readonly blockedByAdmin?: true; readonly oauthErrorCode?: string } => | |
| 830 | + failure.blockedByAdmin === true | |
| 831 | + ? { | |
| 832 | + blockedByAdmin: true, | |
| 833 | + ...(failure.oauthErrorCode === undefined ? {} : { oauthErrorCode: failure.oauthErrorCode }), | |
| 834 | + } | |
| 835 | + : {}; | |
| 836 | + | |
| 821 | 837 | /** The definitive refresh rejection recorded on `provider_state`, or null. |
| 822 | 838 | * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying |
| 823 | 839 | * cannot change the verdict); cleared by the reconnect mint, which rewrites |
| @@ -852,6 +868,10 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 28 unmodified lines | ||
| 852 | 868 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), |
| 853 | 869 | missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), |
| 854 | 870 | lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), |
| 871 | + // Read from the SAME persisted state the renewal path follows, so the | |
| 872 | + // console and the credential lifecycle can never disagree about which | |
| 873 | + // connections are enterprise-managed. | |
| 874 | + enterpriseManaged: enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)) !== null, | |
| 855 | 875 | }; |
| 856 | 876 | }; |
| 857 | 877 | |
| @@ -3455,6 +3475,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2597 unmodified lines | ||
| 3455 | 3475 | checkedAt: Date.now(), |
| 3456 | 3476 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field |
| 3457 | 3477 | detail: err.message, |
| 3478 | + ...healthAdministratorVerdict(err), | |
| 3458 | 3479 | }) |
| 3459 | 3480 | : Effect.fail( |
| 3460 | 3481 | new StorageError({ |
| @@ -3473,6 +3494,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines | ||
| 3473 | 3494 | checkedAt: Date.now(), |
| 3474 | 3495 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field |
| 3475 | 3496 | detail: failure.message, |
| 3497 | + ...healthAdministratorVerdict(failure), | |
| 3476 | 3498 | } |
| 3477 | 3499 | : { |
| 3478 | 3500 | status: "degraded", |
| @@ -81,6 +81,19 @@ export const HealthCheckResult = Schema.Struct({ · 80 unmodified lines | ||
| 81 | 81 | /** Bounded sample of scalar fields from the response body, for the live |
| 82 | 82 | * preview ("show me what this operation returns"). */ |
| 83 | 83 | responseSample: Schema.optional(Schema.Array(HealthCheckResponseSample)), |
| 84 | + /** True when an enterprise identity provider declined to authorize this | |
| 85 | + * connection under administrator policy while resolving its credential. | |
| 86 | + * | |
| 87 | + * Carried as a FIELD beside `status` rather than folded into `detail`, | |
| 88 | + * because the two verdicts demand different products: an expired grant says | |
| 89 | + * "sign in again", an administrator decision says "your organization | |
| 90 | + * decided this", and a console that offered the interactive flow for the | |
| 91 | + * second would walk the user around the control the IdP just exercised. A | |
| 92 | + * console must branch on this, never on the wording of `detail`. */ | |
| 93 | + blockedByAdmin: Schema.optional(Schema.Boolean), | |
| 94 | + /** The authorization server's RFC 6749 §5.2 error code, when the failure came | |
| 95 | + * from a token-endpoint refusal. Shown for support traceability. */ | |
| 96 | + oauthErrorCode: Schema.optional(Schema.String), | |
| 84 | 97 | }); |
| 85 | 98 | export type HealthCheckResult = typeof HealthCheckResult.Type; |
| 86 | 99 | |
| @@ -400,6 +400,9 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 399 unmodified lines | ||
| 400 | 400 | oauthScope: null, |
| 401 | 401 | missingOAuthScopes: [], |
| 402 | 402 | lastHealth: null, |
| 403 | + // A pasted credential is never enterprise-managed: that state is | |
| 404 | + // only ever written by the ID-JAG connect path. | |
| 405 | + enterpriseManaged: false, | |
| 403 | 406 | }; |
| 404 | 407 | return [optimistic, ...rows]; |
| 405 | 408 | }), |