History

Project enterprise-managed state onto connections and health results

Rhys Sullivan committed 2 months ago

Commit 7f0bfa11 parent 2c75d7a8 files · +66 −0Browse files

Unified · Split

8 files
  1. packages/core/api/src/connections/api.ts +5 −0
  2. packages/core/api/src/handlers/connections.ts +1 −0
  3. packages/core/api/src/handlers/oauth.ts +1 −0
  4. packages/core/api/src/oauth/api.ts +6 −0
  5. packages/core/sdk/src/connection.ts +15 −0
  6. packages/core/sdk/src/executor.ts +22 −0
  7. packages/core/sdk/src/health-check.ts +13 −0
  8. packages/react/src/api/atoms.tsx +3 −0
packages/core/api/src/connections/api.ts+5−0
@@ -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
67const ToolResponse = Schema.Struct({72const ToolResponse = Schema.Struct({
packages/core/api/src/handlers/connections.ts+1−0
@@ -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
35const toolToResponse = (t: Tool) => ({36const toolToResponse = (t: Tool) => ({
packages/core/api/src/handlers/oauth.ts+1−0
@@ -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
51const startResultToResponse = (result: ConnectResult) =>52const startResultToResponse = (result: ConnectResult) =>
packages/core/api/src/oauth/api.ts+6−0
@@ -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// ---------------------------------------------------------------------------
packages/core/sdk/src/connection.ts+15−0
@@ -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). */
packages/core/sdk/src/executor.ts+22−0
@@ -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. */
826export 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 — retrying838 * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying
823 * cannot change the verdict); cleared by the reconnect mint, which rewrites839 * 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` field3476 // 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` field3495 // 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",
packages/core/sdk/src/health-check.ts+13−0
@@ -81,6 +81,19 @@ export const HealthCheckResult = Schema.Struct({ · 80 unmodified lines
81 /** Bounded sample of scalar fields from the response body, for the live81 /** 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});
85export type HealthCheckResult = typeof HealthCheckResult.Type;98export type HealthCheckResult = typeof HealthCheckResult.Type;
86 99
packages/react/src/api/atoms.tsx+3−0
@@ -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 }),