Unified · Split
| @@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({ · 61 unmodified lines | |||
| 62 | // Last persisted health-check verdict (written by every checkHealth run), | 62 | // Last persisted health-check verdict (written by every checkHealth run), |
| 63 | // so the list can show alive/expired at a glance without probing. | 63 | // so the list can show alive/expired at a glance without probing. |
| 64 | lastHealth: Schema.NullOr(HealthCheckResult), | 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 | const ToolResponse = Schema.Struct({ | 72 | const ToolResponse = Schema.Struct({ |
| @@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({ · 29 unmodified lines | |||
| 30 | oauthScope: c.oauthScope ?? null, | 30 | oauthScope: c.oauthScope ?? null, |
| 31 | missingOAuthScopes: c.missingOAuthScopes ?? [], | 31 | missingOAuthScopes: c.missingOAuthScopes ?? [], |
| 32 | lastHealth: c.lastHealth ?? null, | 32 | lastHealth: c.lastHealth ?? null, |
| 33 | enterpriseManaged: c.enterpriseManaged ?? false, | ||
| 33 | }); | 34 | }); |
| 34 | 35 | ||
| 35 | const toolToResponse = (t: Tool) => ({ | 36 | const toolToResponse = (t: Tool) => ({ |
| @@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({ · 45 unmodified lines | |||
| 46 | oauthClientOwner: c.oauthClientOwner ?? null, | 46 | oauthClientOwner: c.oauthClientOwner ?? null, |
| 47 | oauthScope: c.oauthScope ?? null, | 47 | oauthScope: c.oauthScope ?? null, |
| 48 | missingOAuthScopes: c.missingOAuthScopes ?? [], | 48 | missingOAuthScopes: c.missingOAuthScopes ?? [], |
| 49 | enterpriseManaged: c.enterpriseManaged ?? false, | ||
| 49 | }); | 50 | }); |
| 50 | 51 | ||
| 51 | const startResultToResponse = (result: ConnectResult) => | 52 | const startResultToResponse = (result: ConnectResult) => |
| @@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({ · 51 unmodified lines | |||
| 52 | oauthClientOwner: Schema.NullOr(Owner), | 52 | oauthClientOwner: Schema.NullOr(Owner), |
| 53 | oauthScope: Schema.NullOr(Schema.String), | 53 | oauthScope: Schema.NullOr(Schema.String), |
| 54 | missingOAuthScopes: Schema.Array(Schema.String), | 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 | * "has this expired?" at a glance in the connections list without probing. | 61 | * "has this expired?" at a glance in the connections list without probing. |
| 62 | * Null/absent = never checked. */ | 62 | * Null/absent = never checked. */ |
| 63 | readonly lastHealth?: HealthCheckResult | null; | 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 | /** Identify one connection — unique by (owner, integration, name). */ | 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 | /** The definitive refresh rejection recorded on `provider_state`, or null. | 837 | /** The definitive refresh rejection recorded on `provider_state`, or null. |
| 822 | * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying | 838 | * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying |
| 823 | * cannot change the verdict); cleared by the reconnect mint, which rewrites | 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 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), | 868 | oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), |
| 853 | missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), | 869 | missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), |
| 854 | lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), | 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 | checkedAt: Date.now(), | 3475 | checkedAt: Date.now(), |
| 3456 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field | 3476 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field |
| 3457 | detail: err.message, | 3477 | detail: err.message, |
| 3478 | ...healthAdministratorVerdict(err), | ||
| 3458 | }) | 3479 | }) |
| 3459 | : Effect.fail( | 3480 | : Effect.fail( |
| 3460 | new StorageError({ | 3481 | new StorageError({ |
| @@ -3473,6 +3494,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 12 unmodified lines | |||
| 3473 | checkedAt: Date.now(), | 3494 | checkedAt: Date.now(), |
| 3474 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field | 3495 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field |
| 3475 | detail: failure.message, | 3496 | detail: failure.message, |
| 3497 | ...healthAdministratorVerdict(failure), | ||
| 3476 | } | 3498 | } |
| 3477 | : { | 3499 | : { |
| 3478 | status: "degraded", | 3500 | status: "degraded", |
| @@ -81,6 +81,19 @@ export const HealthCheckResult = Schema.Struct({ · 80 unmodified lines | |||
| 81 | /** Bounded sample of scalar fields from the response body, for the live | 81 | /** Bounded sample of scalar fields from the response body, for the live |
| 82 | * preview ("show me what this operation returns"). */ | 82 | * preview ("show me what this operation returns"). */ |
| 83 | responseSample: Schema.optional(Schema.Array(HealthCheckResponseSample)), | 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 | export type HealthCheckResult = typeof HealthCheckResult.Type; | 98 | export type HealthCheckResult = typeof HealthCheckResult.Type; |
| 86 | 99 | ||
| @@ -400,6 +400,9 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 399 unmodified lines | |||
| 400 | oauthScope: null, | 400 | oauthScope: null, |
| 401 | missingOAuthScopes: [], | 401 | missingOAuthScopes: [], |
| 402 | lastHealth: null, | 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 | return [optimistic, ...rows]; | 407 | return [optimistic, ...rows]; |
| 405 | }), | 408 | }), |