Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vercel-mcp-refresh-token.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/core/sdk/src/oauth-register-dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
44 changes: 38 additions & 6 deletions packages/core/sdk/src/oauth-scope-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,19 +159,22 @@ 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 });
yield* executor.mcp.seed();
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",
Expand Down Expand Up @@ -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",
() =>
Expand Down
25 changes: 24 additions & 1 deletion packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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,
Expand All @@ -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 },
Expand Down Expand Up @@ -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.
Expand Down
Loading