History

Trace the OAuth lifecycle: token requests, connects, and health checks (#1485)

The OAuth domain layer had no spans. The token POST also bypasses Effect's HttpClient (oauth4webapi drives the raw fetch), so not even an http.client span existed underneath — AS latency and refusal rates were unmeasurable, and health verdicts were only obtainable by querying the database. - executor.oauth.token_request on all three grants: grant type, token hostname, client auth method, and the AS's RFC 6749 error code on failure. Hostname only, never the full URL. - executor.oauth.complete around the code redemption: integration, client slug, whether the token host was rebound to a regional endpoint, and the connection's freshness facts at birth (refresh token present, expiry advertised, scope shortfall counts). - executor.connection.health.check / executor.connection.validate with the verdict, its source (cache, probe, credential-only, no-capability), and http status. mcp.plugin.check_health gets the same outcome stamp — its failures fold onto the success channel, so the span previously ended green on an expired credential. - The AS error code is now a typed oauthErrorCode field on CredentialResolutionError and a structured detail on the sandbox-facing failure, instead of surviving only inside message text.

Rhys Sullivan committed 3 months ago

Commit f78b8ca1 parent b7c23337 files · +217 −25Browse files

Unified · Split

7 files
  1. e2e/scenarios/oauth-refresh-rejected.test.ts +5 −0
  2. packages/core/execution/src/tool-invoker.ts +8 −0
  3. packages/core/sdk/src/errors.ts +7 −0
  4. packages/core/sdk/src/executor.ts +72 −16
  5. packages/core/sdk/src/oauth-helpers.ts +64 −3
  6. packages/core/sdk/src/oauth-service.ts +44 −5
  7. packages/plugins/mcp/src/sdk/plugin.ts +17 −1
e2e/scenarios/oauth-refresh-rejected.test.ts+5−0
@@ -118,6 +118,7 @@ type ToolEnvelope = { · 117 unmodified lines
118 readonly details?: {118 readonly details?: {
119 readonly category?: string;119 readonly category?: string;
120 readonly recovery?: Record<string, string>;120 readonly recovery?: Record<string, string>;
121 readonly upstream?: { readonly details?: { readonly oauthErrorCode?: string } };
121 };122 };
122 };123 };
123};124};
@@ -249,6 +250,10 @@ scenario( · 125 unmodified lines
249 failure.error?.message ?? "",250 failure.error?.message ?? "",
250 "the message carries the AS's RFC 6749 error code",251 "the message carries the AS's RFC 6749 error code",
251 ).toContain("invalid_request");252 ).toContain("invalid_request");
253 expect(
254 failure.error?.details?.upstream?.details?.oauthErrorCode,
255 "the AS's code is ALSO a structured field, so agents and telemetry never parse the message for it",
256 ).toBe("invalid_request");
252 expect(257 expect(
253 failure.error?.message ?? "",258 failure.error?.message ?? "",
254 "the message carries the AS's error description",259 "the message carries the AS's error description",
packages/core/execution/src/tool-invoker.ts+8−0
@@ -177,6 +177,7 @@ const credentialResolutionToolFailure = (input: { · 176 unmodified lines
177 readonly label: string;177 readonly label: string;
178 readonly message: string;178 readonly message: string;
179 readonly reauthRequired?: boolean;179 readonly reauthRequired?: boolean;
180 readonly oauthErrorCode?: string;
180}) =>181}) =>
181 authToolFailure({182 authToolFailure({
182 code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_refresh_failed",183 code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_refresh_failed",
@@ -188,6 +189,12 @@ const credentialResolutionToolFailure = (input: { · 5 unmodified lines
188 kind: "oauth",189 kind: "oauth",
189 label: input.label,190 label: input.label,
190 },191 },
192 // The AS's own RFC 6749 §5.2 verdict, when there was one — structured so
193 // the agent (and anyone reading the failure) can distinguish a dead grant
194 // from a misconfigured app without parsing the message.
195 ...(input.oauthErrorCode !== undefined
196 ? { upstream: { details: { oauthErrorCode: input.oauthErrorCode } } }
197 : {}),
191 });198 });
192 199
193const bindingToolFailure = (value: unknown): ToolError | null => {200const bindingToolFailure = (value: unknown): ToolError | null => {
@@ -310,6 +317,7 @@ export const makeExecutorToolInvoker = ( · 116 unmodified lines
310 label: `${err.integration}.${err.owner}.${err.name}`,317 label: `${err.integration}.${err.owner}.${err.name}`,
311 message: err.message,318 message: err.message,
312 reauthRequired: err.reauthRequired,319 reauthRequired: err.reauthRequired,
320 oauthErrorCode: err.oauthErrorCode,
313 }),321 }),
314 ),322 ),
315 ),323 ),
packages/core/sdk/src/errors.ts+7−0
@@ -181,6 +181,13 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass<Credentia · 180 unmodified lines
181 /** True when the stored grant is permanently invalid and the user must181 /** True when the stored grant is permanently invalid and the user must
182 * sign in again (RFC 6749 §5.2 invalid_grant and friends). */182 * sign in again (RFC 6749 §5.2 invalid_grant and friends). */
183 reauthRequired: Schema.optional(Schema.Boolean),183 reauthRequired: Schema.optional(Schema.Boolean),
184 /** The authorization server's RFC 6749 §5.2 error code (`invalid_grant`,
185 * `invalid_client`, …), when the failure came from a token-endpoint
186 * refusal. A typed field rather than message text so telemetry and
187 * classification read it structurally — the misclassification where
188 * `invalid_client` (rotated app secret, fleet-wide) surfaced as a vague
189 * "degraded" was only findable by grepping persisted message strings. */
190 oauthErrorCode: Schema.optional(Schema.String),
184 },191 },
185) {}192) {}
186 193
packages/core/sdk/src/executor.ts+72−16
@@ -1804,6 +1804,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1803 unmodified lines
1804 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`1804 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`
1805 message: `OAuth token refresh was rejected (${cause.error}): ${cause.message}`,1805 message: `OAuth token refresh was rejected (${cause.error}): ${cause.message}`,
1806 reauthRequired: cause.error === "invalid_grant",1806 reauthRequired: cause.error === "invalid_grant",
1807 oauthErrorCode: cause.error,
1807 });1808 });
1808 }1809 }
1809 return new StorageError({1810 return new StorageError({
@@ -1849,16 +1850,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 39 unmodified lines
1849 // The refresh path was previously invisible to telemetry: no span, no1850 // The refresh path was previously invisible to telemetry: no span, no
1850 // log, no metric. When a customer reported "my OAuth just died", there1851 // log, no metric. When a customer reported "my OAuth just died", there
1851 // was no way to answer "did a refresh even fire, and did it work?"1852 // was no way to answer "did a refresh even fire, and did it work?"
1852 // without a repro. Stamp the outcome and the failure KIND — enumerable1853 // without a repro. Stamp the outcome, the failure KIND, and the AS's
1853 // identifiers only, never user content or token material.1854 // own RFC 6749 §5.2 code (from the typed `oauthErrorCode` field, NOT
1854 //1855 // the message — the message embeds the token endpoint's response URL
1855 // The AS's own RFC 6749 §5.2 code is not stamped here: it survives only1856 // and a body preview). Enumerable identifiers only, never user content
1856 // inside the error's message, and that message embeds the token1857 // or token material. The code is the dimension that separates
1857 // endpoint's response URL and a body preview, so it can't go on a span1858 // invalid_client (a rotated app secret — fleet-wide, page someone)
1858 // attribute. Making that code a queryable dimension needs it lifted to1859 // from server_error (transient, the next invoke retries).
1859 // a typed field on CredentialResolutionError first — worth doing, since
1860 // invalid_client (a rotated secret, fleet-wide) currently looks exactly
1861 // like a transient server_error from a trace.
1862 Effect.tap(() => Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })),1860 Effect.tap(() => Effect.annotateCurrentSpan({ "executor.oauth.refresh.outcome": "ok" })),
1863 Effect.tapError((error: StorageFailure | CredentialResolutionError) =>1861 Effect.tapError((error: StorageFailure | CredentialResolutionError) =>
1864 Effect.annotateCurrentSpan({1862 Effect.annotateCurrentSpan({
@@ -1870,7 +1868,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
1870 // the refresh token itself is dead) or a transient failure the next1868 // the refresh token itself is dead) or a transient failure the next
1871 // invoke can retry. The split is the actionable half of the signal.1869 // invoke can retry. The split is the actionable half of the signal.
1872 ...(Predicate.isTagged(error, "CredentialResolutionError")1870 ...(Predicate.isTagged(error, "CredentialResolutionError")
1873 ? { "executor.oauth.refresh.reauth_required": error.reauthRequired === true }1871 ? {
1872 "executor.oauth.refresh.reauth_required": error.reauthRequired === true,
1873 ...(error.oauthErrorCode !== undefined
1874 ? { "executor.oauth.error_code": error.oauthErrorCode }
1875 : {}),
1876 }
1874 : {}),1877 : {}),
1875 }),1878 }),
1876 ),1879 ),
@@ -3092,6 +3095,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1215 unmodified lines
3092 return out;3095 return out;
3093 });3096 });
3094 3097
3098 /** Stamp the verdict + which path produced it onto the enclosing health
3099 * span. Every health outcome is HTTP 200, so WITHOUT these attributes the
3100 * request envelope cannot separate healthy from expired — the "what
3101 * fraction of connections are dead right now" question was previously
3102 * only answerable by querying the database. `status` and `httpStatus`
3103 * are enumerable; `detail`/`identity` (upstream free text / an email)
3104 * never go on a span. */
3105 const annotateHealthVerdict = (
3106 source: "cache" | "no_capability" | "credential_only" | "probe",
3107 result: HealthCheckResult,
3108 ): Effect.Effect<void> =>
3109 Effect.annotateCurrentSpan({
3110 "executor.health.status": result.status,
3111 "executor.health.source": source,
3112 ...(result.httpStatus !== undefined
3113 ? { "executor.health.http_status": result.httpStatus }
3114 : {}),
3115 });
3116
3095 const connectionCheckHealth = (3117 const connectionCheckHealth = (
3096 ref: ConnectionRef,3118 ref: ConnectionRef,
3097 options?: {3119 options?: {
@@ -3116,7 +3138,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
3116 }3138 }
3117 if (options?.ifStaleMs !== undefined) {3139 if (options?.ifStaleMs !== undefined) {
3118 const cached = Option.getOrNull(decodeLastHealth(connectionRow.last_health));3140 const cached = Option.getOrNull(decodeLastHealth(connectionRow.last_health));
3119 if (cached && Date.now() - cached.checkedAt < options.ifStaleMs) return cached;3141 if (cached && Date.now() - cached.checkedAt < options.ifStaleMs) {
3142 yield* annotateHealthVerdict("cache", cached);
3143 return cached;
3144 }
3120 }3145 }
3121 const integrationRow = yield* findIntegrationRow(ref.integration);3146 const integrationRow = yield* findIntegrationRow(ref.integration);
3122 if (!integrationRow) {3147 if (!integrationRow) {
@@ -3124,10 +3149,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
3124 }3149 }
3125 const runtime = runtimes.get(integrationRow.plugin_id);3150 const runtime = runtimes.get(integrationRow.plugin_id);
3126 const check = runtime?.plugin.checkHealth;3151 const check = runtime?.plugin.checkHealth;
3127 if (!runtime || !check) return unknownHealth();3152 if (!runtime || !check) {
3153 const result = unknownHealth();
3154 yield* annotateHealthVerdict("no_capability", result);
3155 return result;
3156 }
3128 const spec = describeHealthCheckForRow(integrationRow) ?? undefined;3157 const spec = describeHealthCheckForRow(integrationRow) ?? undefined;
3129 if (spec === undefined && connectionRow.oauth_client != null) {3158 if (spec === undefined && connectionRow.oauth_client != null) {
3159 // No probe operation is declared, so "healthy" here means only "the
3160 // credential resolved (refreshing if due)" — a refresh failure is
3161 // the one real signal this path can produce, and it must not hide
3162 // inside a green span.
3130 const result = yield* oauthCredentialHealthWithoutProbe(connectionRow);3163 const result = yield* oauthCredentialHealthWithoutProbe(connectionRow);
3164 yield* annotateHealthVerdict("credential_only", result);
3131 yield* persistHealthResult(ref, result);3165 yield* persistHealthResult(ref, result);
3132 return result;3166 return result;
3133 }3167 }
@@ -3156,12 +3190,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines
3156 `Health check for connection "${ref.name}" failed.`,3190 `Health check for connection "${ref.name}" failed.`,
3157 );3191 );
3158 }).pipe(Effect.catchTag("CredentialResolutionError", healthFromCredentialResolutionError));3192 }).pipe(Effect.catchTag("CredentialResolutionError", healthFromCredentialResolutionError));
3193 yield* annotateHealthVerdict("probe", result);
3159 // Persist the verdict on the connection row so the accounts list shows3194 // Persist the verdict on the connection row so the accounts list shows
3160 // alive/expired at a glance. Best-effort: a write failure must not turn3195 // alive/expired at a glance. Best-effort: a write failure must not turn
3161 // a successful probe into an error.3196 // a successful probe into an error.
3162 yield* persistHealthResult(ref, result);3197 yield* persistHealthResult(ref, result);
3163 return result;3198 return result;
3164 });3199 }).pipe(
3200 Effect.withSpan("executor.connection.health.check", {
3201 attributes: {
3202 "executor.integration": String(ref.integration),
3203 "executor.connection": String(ref.name),
3204 },
3205 }),
3206 );
3165 3207
3166 const connectionValidate = (3208 const connectionValidate = (
3167 input: ValidateConnectionInput,3209 input: ValidateConnectionInput,
@@ -3196,11 +3238,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 28 unmodified lines
3196 // from the integration row. Nothing persists here: validate is the3238 // from the integration row. Nothing persists here: validate is the
3197 // key-first flow's dry run.3239 // key-first flow's dry run.
3198 const spec = input.spec ?? describeHealthCheckForRow(integrationRow) ?? undefined;3240 const spec = input.spec ?? describeHealthCheckForRow(integrationRow) ?? undefined;
3199 return yield* foldPluginFailure(3241 const result = yield* foldPluginFailure(
3200 check({ ctx: runtime.ctx, integration: record, credential, spec }),3242 check({ ctx: runtime.ctx, integration: record, credential, spec }),
3201 `Validating credential for "${input.integration}" failed.`,3243 `Validating credential for "${input.integration}" failed.`,
3202 );3244 );
3203 });3245 // Nothing persists here BY DESIGN, which makes this span the only
3246 // possible record of "what fraction of pasted credentials are rejected
3247 // at the door" — a signal the DB can never carry.
3248 yield* Effect.annotateCurrentSpan({
3249 "executor.health.status": result.status,
3250 ...(result.httpStatus !== undefined
3251 ? { "executor.health.http_status": result.httpStatus }
3252 : {}),
3253 });
3254 return result;
3255 }).pipe(
3256 Effect.withSpan("executor.connection.validate", {
3257 attributes: { "executor.integration": String(input.integration) },
3258 }),
3259 );
3204 3260
3205 // Clear the sync stamp so the next tools read re-produces this connection's3261 // Clear the sync stamp so the next tools read re-produces this connection's
3206 // catalog. The deferred variant of `connectionsRefresh` for signals that3262 // catalog. The deferred variant of `connectionsRefresh` for signals that
packages/core/sdk/src/oauth-helpers.ts+64−3
@@ -367,6 +367,43 @@ const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect<OAuth2Error · 366 unmodified lines
367const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>367const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>
368 toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));368 toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));
369 369
370/** Trace one token-endpoint round trip. This is the ONLY place a token request
371 * can be observed: oauth4webapi drives the raw global `fetch`, not Effect's
372 * HttpClient, so no `http.client` span exists underneath — without this span
373 * the AS's latency and refusal rate are invisible.
374 *
375 * Attribute discipline: hostname (never the full URL — some providers carry
376 * tenant ids in the path), the grant literal, the auth method, and on failure
377 * the AS's RFC 6749 §5.2 code. Never the OAuth2Error message (it embeds the
378 * response URL and a body preview), never token or code material. */
379const withTokenRequestSpan =
380 (input: {
381 readonly grantType: "authorization_code" | "client_credentials" | "refresh_token";
382 readonly tokenUrl: string;
383 readonly clientAuth: ClientAuthMethod | undefined;
384 readonly hasResource: boolean;
385 }) =>
386 <A>(effect: Effect.Effect<A, OAuth2Error>): Effect.Effect<A, OAuth2Error> =>
387 effect.pipe(
388 Effect.tapError((error) =>
389 Effect.annotateCurrentSpan({
390 ...(error.error !== undefined ? { "executor.oauth.error_code": error.error } : {}),
391 }),
392 ),
393 Effect.withSpan("executor.oauth.token_request", {
394 attributes: {
395 "executor.oauth.grant_type": input.grantType,
396 "executor.oauth.token_host": hostnameForTelemetry(input.tokenUrl),
397 "executor.oauth.client_auth": input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
398 "executor.oauth.has_resource": input.hasResource,
399 },
400 }),
401 );
402
403/** The hostname alone — a malformed URL yields "invalid" rather than leaking
404 * whatever string failed to parse. */
405const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ?? "invalid";
406
370// ---------------------------------------------------------------------------407// ---------------------------------------------------------------------------
371// oauth4webapi adapter helpers408// oauth4webapi adapter helpers
372// ---------------------------------------------------------------------------409// ---------------------------------------------------------------------------
@@ -614,7 +651,15 @@ export const exchangeAuthorizationCode = ( · 241 unmodified lines
614 return await processTokenEndpointResponse(as, client, response);651 return await processTokenEndpointResponse(as, client, response);
615 },652 },
616 catch: (cause) => cause,653 catch: (cause) => cause,
617 }).pipe(Effect.catch(failOAuth2WithHttpSummary));654 }).pipe(
655 Effect.catch(failOAuth2WithHttpSummary),
656 withTokenRequestSpan({
657 grantType: "authorization_code",
658 tokenUrl: input.tokenUrl,
659 clientAuth: input.clientAuth,
660 hasResource: input.resource !== undefined,
661 }),
662 );
618 663
619// ---------------------------------------------------------------------------664// ---------------------------------------------------------------------------
620// Exchange client credentials → tokens (RFC 6749 §4.4)665// Exchange client credentials → tokens (RFC 6749 §4.4)
@@ -669,7 +714,15 @@ export const exchangeClientCredentials = ( · 48 unmodified lines
669 return tokenResponseFrom(result);714 return tokenResponseFrom(result);
670 },715 },
671 catch: (cause) => cause,716 catch: (cause) => cause,
672 }).pipe(Effect.catch(failOAuth2WithHttpSummary));717 }).pipe(
718 Effect.catch(failOAuth2WithHttpSummary),
719 withTokenRequestSpan({
720 grantType: "client_credentials",
721 tokenUrl: input.tokenUrl,
722 clientAuth: input.clientAuth,
723 hasResource: input.resource !== undefined,
724 }),
725 );
673 726
674// ---------------------------------------------------------------------------727// ---------------------------------------------------------------------------
675// Refresh access token728// Refresh access token
@@ -740,7 +793,15 @@ export const refreshAccessToken = ( · 64 unmodified lines
740 return tokenResponseFrom(result);793 return tokenResponseFrom(result);
741 },794 },
742 catch: (cause) => cause,795 catch: (cause) => cause,
743 }).pipe(Effect.catch(failOAuth2WithHttpSummary));796 }).pipe(
797 Effect.catch(failOAuth2WithHttpSummary),
798 withTokenRequestSpan({
799 grantType: "refresh_token",
800 tokenUrl: input.tokenUrl,
801 clientAuth: input.clientAuth,
802 hasResource: input.resource !== undefined,
803 }),
804 );
744 805
745// ---------------------------------------------------------------------------806// ---------------------------------------------------------------------------
746// Refresh-needed predicate807// Refresh-needed predicate
packages/core/sdk/src/oauth-service.ts+44−5
@@ -1283,6 +1283,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1282 unmodified lines
1283 clientOwnerFromPayload(sessionRow.payload) ?? (String(sessionRow.owner) as Owner),1283 clientOwnerFromPayload(sessionRow.payload) ?? (String(sessionRow.owner) as Owner),
1284 };1284 };
1285 1285
1286 // Annotate as soon as the session resolves the flow's identity, so even
1287 // a completion that fails at the exchange still says WHOSE connect died.
1288 yield* Effect.annotateCurrentSpan({
1289 "executor.integration": String(session.integration),
1290 "executor.connection": String(session.name),
1291 "executor.template": String(session.template),
1292 "executor.oauth.client": String(session.clientSlug),
1293 });
1294
1286 // Expired sessions are not redeemable — drop + treat as not found.1295 // Expired sessions are not redeemable — drop + treat as not found.
1287 if (Number.isFinite(session.expiresAt) && session.expiresAt <= Date.now()) {1296 if (Number.isFinite(session.expiresAt) && session.expiresAt <= Date.now()) {
1288 yield* deleteSession(input.state);1297 yield* deleteSession(input.state);
@@ -1368,9 +1377,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 79 unmodified lines
1368 ),1377 ),
1369 );1378 );
1370 1379
1380 // Everything a "why did this connect fail / where did it go" question
1381 // needs, none of it secret: slugs, owner scope, and whether the token
1382 // host was rebound to a regional endpoint (the Datadog multi-site path —
1383 // a bug there previously shipped and was only diagnosable by hand).
1384 // Deliberately absent: the code, the PKCE verifier, the token, the
1385 // callback domain (can embed an org's private site), and identityLabel
1386 // (resolves to an email).
1387 yield* Effect.annotateCurrentSpan({
1388 "executor.oauth.token_host_rebound": tokenUrl !== client.tokenUrl,
1389 });
1390
1371 yield* deleteSession(input.state);1391 yield* deleteSession(input.state);
1372 return connection;1392 return connection;
1373 });1393 }).pipe(
1394 Effect.withSpan("executor.oauth.complete", {
1395 attributes: { "executor.oauth.grant": "authorization_code" },
1396 }),
1397 );
1374 1398
1375 // -----------------------------------------------------------------------1399 // -----------------------------------------------------------------------
1376 // Mint the connection from a freshly exchanged token: store the access1400 // Mint the connection from a freshly exchanged token: store the access
@@ -1416,6 +1440,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 39 unmodified lines
1416 }1440 }
1417 1441
1418 const oauthScope = recordedOAuthScope(token, requestedScopes);1442 const oauthScope = recordedOAuthScope(token, requestedScopes);
1443 const missingScopes =
1444 client.grant === "authorization_code"
1445 ? missingGrantedOAuthScopes(requestedScopes, oauthScope)
1446 : [];
1447 // The freshness facts of this connection AT BIRTH, on the enclosing
1448 // span (executor.oauth.complete, or the reconnect path's request
1449 // envelope). Every "why did this connection later go stale" question
1450 // starts here: a partial grant fails later as oauth_scope_insufficient
1451 // in an unrelated trace; no refresh token means the first expiry is
1452 // terminal; no advertised expiry means only the reactive 401 path can
1453 // ever refresh it. Counts and booleans only — scope VALUES can encode
1454 // customer resource names on some providers.
1455 yield* Effect.annotateCurrentSpan({
1456 "executor.oauth.scope_requested_count": requestedScopes.length,
1457 "executor.oauth.scope_missing_count": missingScopes.length,
1458 "executor.oauth.has_refresh_token": token.refresh_token !== undefined,
1459 "executor.oauth.has_advertised_expiry": typeof token.expires_in === "number",
1460 });
1419 return yield* deps.mintOAuthConnection({1461 return yield* deps.mintOAuthConnection({
1420 owner: target.owner,1462 owner: target.owner,
1421 name: target.name,1463 name: target.name,
@@ -1436,10 +1478,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 14 unmodified lines
1436 // non-resource scope from the token `scope` string, so preserve it when1478 // non-resource scope from the token `scope` string, so preserve it when
1437 // the refresh token proves it was granted.1479 // the refresh token proves it was granted.
1438 oauthScope,1480 oauthScope,
1439 missingOAuthScopes:1481 missingOAuthScopes: missingScopes,
1440 client.grant === "authorization_code"
1441 ? missingGrantedOAuthScopes(requestedScopes, oauthScope)
1442 : [],
1443 oauthTokenUrl,1482 oauthTokenUrl,
1444 });1483 });
1445 });1484 });
packages/plugins/mcp/src/sdk/plugin.ts+17−1
@@ -1555,7 +1555,23 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1554 unmodified lines
1555 detail: error.message,1555 detail: error.message,
1556 } satisfies HealthCheckResult),1556 } satisfies HealthCheckResult),
1557 ),1557 ),
1558 Effect.withSpan("mcp.plugin.check_health"),1558 // Every failure above folds onto the SUCCESS channel, so without this
1559 // the span ends green on an expired credential — a 401 auth wall and a
1560 // healthy dial were indistinguishable in traces.
1561 Effect.tap((result) =>
1562 Effect.annotateCurrentSpan({
1563 "mcp.health.status": result.status,
1564 ...("httpStatus" in result && result.httpStatus !== undefined
1565 ? { "mcp.health.http_status": result.httpStatus }
1566 : {}),
1567 }),
1568 ),
1569 Effect.withSpan("mcp.plugin.check_health", {
1570 attributes: {
1571 "executor.integration": String(credential.integration),
1572 "mcp.connection.name": String(credential.connection),
1573 },
1574 }),
1559 ),1575 ),
1560 1576
1561 describeAuthMethods: describeMcpAuthMethods,1577 describeAuthMethods: describeMcpAuthMethods,