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.
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => {
resetBuildSlotsForTest();
});

afterEach(() => {
vi.useRealTimers();
});

it("grants up to the cap immediately, with no wait", async () => {
const results = await Promise.all([
acquireBuildSlot().promise,
Expand Down Expand Up @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => {
});

it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => {
vi.useFakeTimers();
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
Expand All @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => {
expect(currentActiveBuildsForTest()).toBe(4);

const timedOutHandle = acquireBuildSlot(10);
await vi.advanceTimersByTimeAsync(9);
expect(currentQueueLengthForTest()).toBe(1);
expect(currentActiveBuildsForTest()).toBe(4);
await vi.advanceTimersByTimeAsync(1);
const result = await timedOutHandle.promise;

expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });
Expand Down
146 changes: 146 additions & 0 deletions e2e/selfhost/vercel-oauth-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { connectEmulator } from "@executor-js/emulate";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import {
AuthTemplateSlug,
ConnectionName,
IntegrationSlug,
OAuthClientSlug,
} from "@executor-js/sdk/shared";

import { createEmulatorInstance } from "../src/emulator-instance";
import { scenario } from "../src/scenario";
import { Api, Browser, Mcp, Target } from "../src/services";

const api = composePluginApi([mcpHttpPlugin()] as const);

scenario(
"Vercel OAuth · lifecycle scopes survive registration and a complete MCP connection",
{ timeout: 180_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const mcp = yield* Mcp;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const session = mcp.session(identity);
// Authenticate the host MCP transport before minting upstream credentials.
// The hosted emulator currently loses its OAuth token map on eviction.
const ready = yield* session.call("execute", { code: "return true;" });
expect(ready.ok).toBe(true);
const base = yield* createEmulatorInstance("mcp", "vercel-lifecycle");
const emulator = yield* Effect.promise(() =>
connectEmulator({ baseUrl: base, service: "mcp" }),
);
yield* Effect.promise(() =>
emulator.seed({ users: [{ login: "lifecycle-user" }], scopes: ["openid"] }),
);
const slug = IntegrationSlug.make(`lifecycle-${randomBytes(4).toString("hex")}`);
const app = OAuthClientSlug.make(`${slug}-app`);
yield* Effect.addFinalizer(() =>
client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore),
);
yield* client.mcp.addServer({
payload: {
transport: "remote",
name: "Lifecycle MCP",
endpoint: `${base}/mcp`,
slug,
authenticationTemplate: [{ kind: "oauth2" }],
},
});
const registered = yield* client.oauth.registerDynamic({
payload: {
owner: "org",
slug: app,
issuer: base,
registrationEndpoint: `${base}/register`,
authorizationUrl: "https://vercel.com/oauth/authorize",
tokenUrl: `${base}/token`,
resource: `${base}/mcp`,
scopes: ["openid"],
tokenEndpointAuthMethodsSupported: ["none"],
originIntegration: slug,
},
});
yield* Effect.addFinalizer(() =>
client.oauth
.removeClient({ params: { slug: registered.client }, payload: { owner: "org" } })
.pipe(Effect.ignore),
);
const started = yield* client.oauth.start({
payload: {
owner: "org",
client: registered.client,
clientOwner: "org",
name: ConnectionName.make("main"),
integration: slug,
template: AuthTemplateSlug.make("oauth2"),
},
});
if (started.status !== "redirect")
return yield* Effect.die("Expected authorization redirect");
yield* Effect.addFinalizer(() =>
client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore),
);
const authorize = new URL(started.authorizationUrl);
expect(authorize.origin + authorize.pathname).toBe("https://vercel.com/oauth/authorize");
expect(authorize.searchParams.get("scope")?.split(" ")).toEqual(["openid", "offline_access"]);
// Route only the provider transport to the published emulator. The product
// produced all OAuth parameters; DCR, PKCE, consent, callback and MCP are real.
// This fixture does not claim to prove Vercel's refresh-token issuance policy.
const consentUrl = `${base}/authorize${authorize.search}`;
yield* browser.session(identity, async ({ page, step }) => {
await step("Review the provider consent request", async () => {
await page.goto(consentUrl);
await page.getByText("Authorize MCP client", { exact: true }).waitFor();
});
await step("Approve the requested access and complete the callback", async () => {
const form = Object.fromEntries(authorize.searchParams);
const approved = await page.request.post(`${base}/authorize/approve`, {
form: { ...form, login: "lifecycle-user" },
maxRedirects: 0,
});
expect(approved.status()).toBe(302);
const callback = approved.headers().location;
expect(callback).toBeDefined();
await page.goto(callback!);
await page
.getByText(/connected|complete|success/i)
.first()
.waitFor({ timeout: 30_000 });
});
});
const tools = yield* client.tools.list({ query: { integration: slug } });
const tool = tools.find((entry) => entry.name === "get_me");
expect(tool).toBeDefined();
if (!tool) return yield* Effect.die("Connected MCP has no get_me tool");
let result = yield* session.call("execute", {
code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`,
});
for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1)
result = yield* session.approvePaused(result.text);
expect(result.ok).toBe(true);
expect(result.text).toContain("lifecycle-user");
const ledger = yield* Effect.promise(() => emulator.ledger.list());
const registration = ledger.find(
(entry) => entry.method === "POST" && entry.path.endsWith("/register"),
);
expect(registration?.request.body).toMatchObject({ scope: "openid offline_access" });
expect(
ledger.some(
(entry) =>
entry.method === "POST" &&
entry.path.endsWith("/token") &&
entry.response.status === 200,
),
).toBe(true);
}),
),
);
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
66 changes: 60 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,57 @@ 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("does not add Vercel lifecycle scopes on a different port", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveMetadataServer({ prm: { scopesSupported: ["openid"] } });
const executor = yield* setupMcpScopeClient(server, {
authorizationEndpoint: "https://vercel.com:8443/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"]);
}),
),
);

it.effect(
"(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata",
() =>
Expand Down
24 changes: 23 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,23 @@ 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.origin === "https://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 +1428,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 +1442,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 +1995,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