Skip to content

Commit 9dea326

Browse files
RhysSullivanclaude
andcommitted
fix(oauth): request all advertised scopes, keep sync verdicts visible
Scope discovery capped the request at 100 scopes. A resource that advertises more (PostHog lists 150) got a token missing the scopes its MCP server needs, so every new connection synced zero tools. Bound the request by scope-string length (8 KiB) instead. A credential-only health check then reported healthy over the sync-stamped rejection, hiding the failure. Sync-supplied verdicts now carry the tool_sync_failed reason and are served until a sync succeeds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3890d6f commit 9dea326

6 files changed

Lines changed: 156 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy".

‎packages/core/sdk/src/executor.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3605,7 +3605,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
36053605
? { tools_synced_at: Date.now() }
36063606
: {
36073607
tools_synced_at: Date.now(),
3608-
last_health: health ?? toolSyncHealth(reason),
3608+
// A plugin-supplied verdict (e.g. the MCP server
3609+
// rejecting the token during discovery) is still
3610+
// sync-stamped: mark it so credential-only health
3611+
// checks cannot bury it under "healthy", and a
3612+
// later successful sync clears it.
3613+
last_health:
3614+
health === undefined
3615+
? toolSyncHealth(reason)
3616+
: { ...health, reason: health.reason ?? "tool_sync_failed" },
36093617
updated_at: new Date(),
36103618
},
36113619
})
@@ -5138,7 +5146,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
51385146
): Effect.Effect<void> =>
51395147
findConnectionRow(ref).pipe(
51405148
Effect.flatMap((fresh) =>
5141-
fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null
5149+
fresh === null ||
5150+
oauthReauthRequiredFromProviderState(fresh.provider_state) !== null ||
5151+
// A credential verdict cannot refute a failed tool sync; only a
5152+
// successful sync clears that record (see `isToolSyncHealth`).
5153+
isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health)))
51425154
? Effect.void
51435155
: persistHealthResult(ref, fresh, result),
51445156
),
@@ -5241,6 +5253,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
52415253
// failure is the one real signal this path can produce, and it
52425254
// must not hide inside a green span.
52435255
oauthCredentialHealthWithoutProbe(connectionRow).pipe(
5256+
// A resolvable token says nothing about whether the
5257+
// upstream accepts it. When tool sync has already recorded
5258+
// that it does not (a rejected discovery handshake, an
5259+
// unreachable server), that verdict stands until a sync
5260+
// succeeds — serving "healthy" here would hide a connection
5261+
// that has no tools behind a green badge.
5262+
Effect.map((result) =>
5263+
result.status === "healthy" && previous !== null && isToolSyncHealth(previous)
5264+
? previous
5265+
: result,
5266+
),
52445267
Effect.tap((result) => persistProbeHealthResult(ref, result)),
52455268
Effect.map((result) => ({
52465269
source: "credential_only" as const,

‎packages/core/sdk/src/health-check.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type;
145145
export const toolSyncHealthDetailPrefix = "Tool sync failing";
146146

147147
export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean =>
148+
result?.reason === "tool_sync_failed" ||
148149
result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;
149150

150151
// ---------------------------------------------------------------------------

‎packages/core/sdk/src/oauth-flow.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,73 @@ describe("oauth token refresh in resolveConnectionValue", () => {
20332033
),
20342034
);
20352035

2036+
it.effect(
2037+
"checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy",
2038+
() =>
2039+
Effect.scoped(
2040+
Effect.gen(function* () {
2041+
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2042+
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2043+
yield* executor.acme.seed();
2044+
2045+
yield* executor.oauth.createClient({
2046+
owner: "org",
2047+
slug: CLIENT,
2048+
authorizationUrl: server.authorizationEndpoint,
2049+
tokenUrl: server.tokenEndpoint,
2050+
grant: "authorization_code",
2051+
clientId: "test-client",
2052+
clientSecret: "test-secret",
2053+
resource: server.mcpResourceUrl,
2054+
});
2055+
2056+
const started = yield* executor.oauth.start({
2057+
owner: "org",
2058+
client: CLIENT,
2059+
clientOwner: "org",
2060+
name: ConnectionName.make("main"),
2061+
integration: INTEG,
2062+
template: TEMPLATE,
2063+
});
2064+
expect(started.status).toBe("redirect");
2065+
if (started.status !== "redirect") return;
2066+
const callback = yield* server.completeAuthorizationCodeFlow({
2067+
authorizationUrl: started.authorizationUrl,
2068+
});
2069+
yield* executor.oauth.complete({ state: started.state, code: callback.code });
2070+
2071+
// Tool sync found the upstream rejecting the freshly minted token
2072+
// (e.g. an MCP discovery handshake answering 401) and stamped it.
2073+
// The token itself still resolves, so a credential-only check would
2074+
// otherwise report healthy and hide a connection that has no tools.
2075+
const stamped = {
2076+
status: "expired",
2077+
checkedAt: Date.now(),
2078+
detail: "MCP OAuth reauthorization required",
2079+
reason: "tool_sync_failed",
2080+
};
2081+
yield* Effect.promise(() =>
2082+
config.db.updateMany("connection", {
2083+
where: (b) => b("name", "=", "main"),
2084+
set: { last_health: stamped },
2085+
}),
2086+
);
2087+
2088+
const result = yield* executor.connections.checkHealth({
2089+
owner: "org",
2090+
integration: INTEG,
2091+
name: ConnectionName.make("main"),
2092+
});
2093+
expect(result).toMatchObject(stamped);
2094+
2095+
const row = yield* Effect.promise(() =>
2096+
config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
2097+
);
2098+
expect(row?.last_health).toMatchObject(stamped);
2099+
}),
2100+
),
2101+
);
2102+
20362103
it.effect("records missing authorization-code scopes without blocking the connection", () =>
20372104
Effect.scoped(
20382105
Effect.gen(function* () {

‎packages/core/sdk/src/oauth-scope-union.test.ts‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -713,13 +713,42 @@ describe("oauth.start integration-driven scopes", () => {
713713
),
714714
);
715715

716-
it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
716+
it.effect("(j) requests every advertised scope of a large but realistic resource list", () =>
717717
Effect.scoped(
718718
Effect.gen(function* () {
719-
// A hostile/buggy server advertises far more scopes than any real
720-
// template. Discovery caps the request at 100 so the authorize URL
721-
// cannot be blown up.
722-
const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`);
719+
// A fine-grained resource can legitimately advertise well over a
720+
// hundred scopes (PostHog lists 150). Dropping any of them mints a
721+
// token the resource rejects, so the whole list must be requested.
722+
const manyScopes = Array.from(
723+
{ length: 150 },
724+
(_, i) => `resource_${i}:${i % 2 === 0 ? "read" : "write"}`,
725+
);
726+
const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
727+
const executor = yield* setupMcpScopeClient(server);
728+
729+
const started = yield* executor.oauth.start({
730+
owner: "org",
731+
client: CLIENT,
732+
clientOwner: "org",
733+
name: ConnectionName.make("main"),
734+
integration: INTEG,
735+
template: TEMPLATE,
736+
});
737+
expect(started.status).toBe("redirect");
738+
if (started.status !== "redirect") return;
739+
740+
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(manyScopes);
741+
}),
742+
),
743+
);
744+
745+
it.effect("(j2) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
746+
Effect.scoped(
747+
Effect.gen(function* () {
748+
// A hostile/buggy server advertises an absurd list. Discovery keeps
749+
// the longest leading prefix whose joined `scope` value fits the
750+
// 8 KiB budget so the authorize URL cannot be blown up.
751+
const manyScopes = Array.from({ length: 2000 }, (_, i) => `scope:${i}`);
723752
const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
724753
const executor = yield* setupMcpScopeClient(server);
725754

@@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => {
735764
if (started.status !== "redirect") return;
736765

737766
const requested = scopesFromAuthorizeUrl(started.authorizationUrl);
738-
expect(requested.length).toBe(100);
739-
expect(requested).toEqual(manyScopes.slice(0, 100));
767+
expect(requested.length).toBeLessThan(manyScopes.length);
768+
expect(requested).toEqual(manyScopes.slice(0, requested.length));
769+
expect(requested.join(" ").length).toBeLessThanOrEqual(8192);
770+
expect([...requested, manyScopes[requested.length]].join(" ").length).toBeGreaterThan(8192);
740771
}),
741772
),
742773
);

‎packages/core/sdk/src/oauth-service.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -782,9 +782,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
782782
// Caps on server-controlled discovery input — a hostile or buggy server must
783783
// not be able to hang `oauth.start` or overflow the authorize URL.
784784
const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice
785-
const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template
786-
const capScopes = (scopes: readonly string[]): readonly string[] =>
787-
dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES);
785+
// The cap is on the encoded `scope` parameter's length, not the scope
786+
// count: the URL is what overflows, and a real resource can legitimately
787+
// advertise well over a hundred fine-grained scopes (PostHog lists 150).
788+
// Dropping any advertised scope silently mints a token the resource then
789+
// rejects, so the budget is generous — 8 KiB leaves room for the rest of the
790+
// authorize URL under the common 8-16 KiB request-line limits — and only an
791+
// absurd list is truncated.
792+
const MAX_DISCOVERED_SCOPE_CHARS = 8192;
793+
const capScopes = (scopes: readonly string[]): readonly string[] => {
794+
const unique = dedupeScopes(scopes);
795+
let length = 0;
796+
let count = 0;
797+
for (const scope of unique) {
798+
const next = length + scope.length + (count > 0 ? 1 : 0);
799+
if (next > MAX_DISCOVERED_SCOPE_CHARS) break;
800+
length = next;
801+
count += 1;
802+
}
803+
return unique.slice(0, count);
804+
};
788805

789806
// Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS
790807
// fetches, each with its own request timeout). 30s is larger than a single

0 commit comments

Comments
 (0)