From 4f3f528be890e5a9399e3b10f74b63e50c142104 Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Sat, 5 Sep 2026 14:09:26 +0530 Subject: [PATCH] fix(oauth): request offline_access for Vercel MCP --- .changeset/vercel-mcp-refresh-token.md | 5 +++ .../sdk/src/oauth-register-dynamic.test.ts | 33 ++++++++++++++ .../core/sdk/src/oauth-scope-union.test.ts | 44 ++++++++++++++++--- packages/core/sdk/src/oauth-service.ts | 25 ++++++++++- 4 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 .changeset/vercel-mcp-refresh-token.md diff --git a/.changeset/vercel-mcp-refresh-token.md b/.changeset/vercel-mcp-refresh-token.md new file mode 100644 index 0000000000..dbcf4e5d2a --- /dev/null +++ b/.changeset/vercel-mcp-refresh-token.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Keep Vercel MCP connections renewable by requesting the provider's `offline_access` lifecycle scope during registration and authorization. diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 6a5a281407..bc13050ded 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -373,6 +373,39 @@ describe("oauth.registerDynamicClient", () => { ), ); + it.effect("registers Vercel clients with offline_access for refresh tokens", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["openid", "offline_access"], + }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: "https://vercel.com", + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: "https://vercel.com/oauth/authorize", + tokenUrl: server.tokenEndpoint, + resource: "https://mcp.vercel.com/", + scopes: ["openid"], + tokenEndpointAuthMethodsSupported: ["none"], + clientName: "Executor", + redirectUri: FLOW_REDIRECT_URI, + originIntegration: INTEG, + }); + + const requests = yield* server.requests; + const registration = requests.find( + (request) => request.path === "/register" && request.method === "POST", + ); + expect(registration?.body).toContain('"scope":"openid offline_access"'); + }), + ), + ); + it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 6e7aa41669..8639e4ebb8 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -159,11 +159,14 @@ const serveMetadataServer = (config: { * given server as its resource, returning the executor ready to `oauth.start`. * The shared setup for the discovery cases below; case (h) inlines its own (no * `resource`) because the absent resource IS the case under test. */ -const setupMcpScopeClient = (server: { - readonly authorizationEndpoint: string; - readonly tokenEndpoint: string; - readonly mcpResourceUrl: string; -}) => +const setupMcpScopeClient = ( + server: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly mcpResourceUrl: string; + }, + options: { readonly authorizationEndpoint?: string } = {}, +) => Effect.gen(function* () { const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); @@ -171,7 +174,7 @@ const setupMcpScopeClient = (server: { yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, - authorizationUrl: server.authorizationEndpoint, + authorizationUrl: options.authorizationEndpoint ?? server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", @@ -405,6 +408,35 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("requests Vercel offline_access so authorization-code connections can refresh", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ + prm: { scopesSupported: ["openid"] }, + }); + const executor = yield* setupMcpScopeClient(server, { + authorizationEndpoint: "https://vercel.com/oauth/authorize", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([ + "openid", + "offline_access", + ]); + }), + ), + ); + it.effect( "(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index dd443144bd..6db880d94b 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -551,6 +551,24 @@ interface LoadedOAuthClient { readonly tokenRequestFormat?: "form" | "json"; } +/** Provider lifecycle scopes that are required to keep an authorization-code + * connection renewable but are omitted from the protected resource's API + * scope list. Vercel's MCP resource advertises only `openid`, while its + * authorization server issues a refresh token only when `offline_access` is + * requested. Keep the exception bound to Vercel's exact official authorize + * endpoint so an unrelated OAuth server never receives a broader request. */ +const additionalAuthorizationLifecycleScopes = (client: { + readonly authorizationUrl: string; +}): readonly string[] => { + if (!URL.canParse(client.authorizationUrl)) return []; + const authorization = new URL(client.authorizationUrl); + return authorization.protocol === "https:" && + authorization.hostname === "vercel.com" && + authorization.pathname === "/oauth/authorize" + ? ["offline_access"] + : []; +}; + /** Where an OAuth app's client secret is stored in the default writable * provider — derived solely from the app's (owner, slug) identity. */ const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string => @@ -1411,6 +1429,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported); + const registrationScopes = dedupeScopes([ + ...input.scopes, + ...additionalAuthorizationLifecycleScopes(input), + ]); const information = yield* registerDynamicClientDcr( { registrationEndpoint: input.registrationEndpoint, @@ -1421,7 +1443,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { response_types: ["code"], token_endpoint_auth_method: authMethod, application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web", - scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined, + scope: registrationScopes.length > 0 ? registrationScopes.join(" ") : undefined, }, }, { httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy }, @@ -1974,6 +1996,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const completeAuthorizationScopes = dedupeScopes([ ...authorizationRequestedScopes, ...(firstParty?.additionalAuthorizationScopes ?? []), + ...additionalAuthorizationLifecycleScopes(client), ]); // authorization_code: persist a session + build the authorize URL.