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
112 changes: 112 additions & 0 deletions e2e/selfhost/mcp-oauth-callback-background-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// An OAuth callback commits the fresh grant before it synchronizes a remote
// MCP catalog. A slow tools/list response must not keep the popup request open;
// the host keeps catalog work alive and the tools converge afterward.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect, Schedule } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
import { IntegrationSlug } from "@executor-js/sdk/shared";
import { OAuthTestServer } from "@executor-js/sdk/testing";

import { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";
import { visit } from "../src/surfaces/browser";

const api = composePluginApi([mcpHttpPlugin()] as const);
const CATALOG_REQUEST_DELAY_MS = 2_000;

const submitProviderLogin = async (loginUrl: string): Promise<string> => {
const response = await fetch(loginUrl, {
method: "POST",
redirect: "manual",
headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
});
const location = response.headers.get("location");
if (response.status !== 302 || !location) {
throw new Error(`provider login did not redirect (${response.status})`);
}
return new URL(location, loginUrl).toString();
};

scenario(
"MCP OAuth · callback closes before a slow remote catalog finishes syncing",
{ timeout: 240_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: makeApiClient } = yield* Api;
const server = yield* serveMcpServerWithOAuth(
() => makeGreetingMcpServer({ name: "slow-callback-mcp" }),
{ path: "/mcp", authenticatedRequestDelayMs: CATALOG_REQUEST_DELAY_MS },
);
const identity = yield* target.newIdentity();
const client = yield* makeApiClient(api, identity);
const displayName = `Slow callback MCP ${randomBytes(3).toString("hex")}`;
const slug = IntegrationSlug.make(deriveMcpNamespace({ name: displayName }));
const clientsBefore = new Set((yield* client.oauth.listClients()).map((item) => item.slug));

yield* Effect.gen(function* () {
yield* browser.session(identity, async ({ page, step }) => {
await step("Add an OAuth-protected MCP integration", async () => {
const addUrl = new URL("/integrations/add/mcp", target.baseUrl);
addUrl.searchParams.set("url", server.endpoint);
await visit(page, addUrl.toString());
await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
await page.getByPlaceholder("e.g. Linear").fill(displayName);
await page.getByRole("button", { name: "Add integration" }).click();
await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
});

await step("Authorize while the MCP catalog is deliberately slow", async () => {
await page.getByRole("button", { name: "Add connection" }).first().click();
await page.getByRole("heading", { name: /Add connection/ }).waitFor();

const popupPromise = page.waitForEvent("popup", { timeout: 30_000 });
await page.getByRole("button", { name: "Connect", exact: true }).click();
const popup = await popupPromise;
await popup.waitForURL(/\/login\?/, { timeout: 30_000 });
const callbackUrl = await submitProviderLogin(popup.url());

// Each authenticated MCP transport request is held for two seconds.
// The callback has 1.5 seconds to render, so this can pass only if
// catalog discovery is no longer part of the callback response.
await popup.goto(callbackUrl, { waitUntil: "domcontentloaded", timeout: 1_500 });
await page.getByText("Connection added", { exact: true }).waitFor({ timeout: 30_000 });
});
});

const tools = yield* client.tools.list({ query: { integration: slug } }).pipe(
Effect.filterOrFail(
(items) => items.some((tool) => String(tool.name) === "simple_echo"),
() => "slow_mcp_catalog_pending" as const,
),
Effect.retry(Schedule.both(Schedule.spaced("1 second"), Schedule.recurs(20))),
);
expect(
tools.map((tool) => String(tool.name)),
"the host-kept background sync eventually publishes the remote tool",
).toContain("simple_echo");
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const clientsAfter = yield* client.oauth.listClients();
for (const oauthClient of clientsAfter) {
if (!clientsBefore.has(oauthClient.slug)) {
yield* client.oauth.removeClient({
params: { slug: oauthClient.slug },
payload: { owner: oauthClient.owner },
});
}
}
yield* client.mcp.removeServer({ params: { slug } });
}).pipe(Effect.ignore),
),
);
}),
).pipe(Effect.provide(OAuthTestServer.layer())),
);
17 changes: 10 additions & 7 deletions packages/core/api/src/handlers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,16 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler
const html = yield* runOAuthCallback({
complete: ({ state, code, callbackDomain }) =>
executor.oauth
.complete({
// `runOAuthCallback`'s `state` is a raw string from the URL;
// the SDK speaks the branded `OAuthState` (nominal brand).
state: OAuthState.make(state),
code: code ?? "",
callbackDomain,
})
.complete(
{
// `runOAuthCallback`'s `state` is a raw string from the URL;
// the SDK speaks the branded `OAuthState` (nominal brand).
state: OAuthState.make(state),
code: code ?? "",
callbackDomain,
},
{ toolSync: "background" },
)
.pipe(
Effect.tapError((cause: unknown) =>
Effect.logError("OAuth callback completion failed", cause),
Expand Down
43 changes: 40 additions & 3 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4118,6 +4118,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// pre-reconnect "expired" outlive the reconnect; the next health
// check writes the verdict for the new grant.
last_health: null,
// A fresh grant invalidates the catalog's freshness even when
// its remote rebuild runs after the OAuth callback responds.
// If that background task is interrupted, the next tools read
// sees this marker and converges it through the normal stale
// catalog path.
tools_synced_at: null,
updated_at: now,
};
if (existing) {
Expand Down Expand Up @@ -4158,11 +4164,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
}),
);

// Produce + persist tools for the minted connection (same path
// connections.create uses).
yield* produceConnectionTools(integrationRow, ref).pipe(
// The connection row and credential are already durable. Interactive
// OAuth callbacks return at this boundary and let remote discovery run
// under the host's keep-alive; otherwise a slow MCP listTools call can
// keep the popup open until the Worker request is cancelled. Explicit
// mints (for example client_credentials) retain the original contract.
const syncTools = produceConnectionTools(
integrationRow,
ref,
input.toolSync ?? "explicit",
).pipe(
Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])),
);
if (input.toolSync === "background") {
const fiber = yield* Effect.forkDetach(
syncTools.pipe(
Effect.catch((error) =>
Effect.logWarning("executor OAuth tool sync failed", {
integration: String(ref.integration),
connection: String(ref.name),
error: describeSyncFailure(error),
}),
),
Effect.withSpan("executor.oauth.tools.sync", {
attributes: {
"executor.integration": String(ref.integration),
"executor.connection": String(ref.name),
},
}),
),
);
config.waitUntil?.(
new Promise<void>((resolve) => fiber.addObserver(() => resolve(undefined))),
);
} else {
yield* syncTools;
}

const row = yield* findConnectionRow(ref);
return row
Expand Down
8 changes: 8 additions & 0 deletions packages/core/sdk/src/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ export interface OAuthCompleteInput {
readonly callbackDomain?: string | null;
}

/** Host-lifecycle behavior for OAuth completion. The HTTP popup uses
* background tool synchronization so it can close after the durable grant;
* programmatic callers keep the default explicit catalog guarantee. */
export interface OAuthCompleteOptions {
readonly toolSync?: "explicit" | "background";
}

/** Probe a base/issuer URL for OAuth 2.1 authorization-server metadata so the
* onboarding UI can pre-fill a client's endpoints. */
export interface OAuthProbeInput {
Expand Down Expand Up @@ -496,6 +503,7 @@ export interface OAuthService {
) => Effect.Effect<ConnectResult, OAuthStartError | StorageFailure>;
readonly complete: (
input: OAuthCompleteInput,
options?: OAuthCompleteOptions,
) => Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure>;
readonly cancel: (state: OAuthState) => Effect.Effect<void, StorageFailure>;
readonly probe: (
Expand Down
89 changes: 88 additions & 1 deletion packages/core/sdk/src/oauth-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { Deferred, Effect, Fiber, Predicate } from "effect";
import { Deferred, Effect, Fiber, Option, Predicate } from "effect";
import { withQueryContext } from "@executor-js/fumadb/query";

import {
Expand Down Expand Up @@ -264,6 +264,93 @@ describe("oauth.start / oauth.complete", () => {
),
);

it.effect("complete returns after the durable grant while remote tool discovery continues", () =>
Effect.scoped(
Effect.gen(function* () {
const discoveryStarted = yield* Deferred.make<void>();
const releaseDiscovery = yield* Deferred.make<void>();
const keptAlive: Promise<unknown>[] = [];
const slowOAuthPlugin = definePlugin(() => ({
id: "acme" as const,
storage: () => ({}),
resolveTools: () =>
Effect.gen(function* () {
yield* Deferred.succeed(discoveryStarted, undefined);
yield* Deferred.await(releaseDiscovery);
return {
tools: [{ name: ToolName.make("whoami"), description: "whoami" }],
};
}),
describeAuthMethods: () => [
{
id: "oauth",
label: "OAuth2",
kind: "oauth" as const,
template: String(TEMPLATE),
oauth: { scopes: ["read"] },
},
],
invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
extension: (ctx) => ({
seed: () =>
ctx.core.integrations.register({
slug: INTEG,
description: "Slow Acme",
config: {},
}),
}),
}))();
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
const { executor } = yield* makeTestWorkspaceHarness({
plugins: [memoryCredentialsPlugin(), slowOAuthPlugin] as const,
waitUntil: (promise) => keptAlive.push(promise),
});
yield* executor.acme.seed();

yield* executor.oauth.createClient({
owner: "org",
slug: CLIENT,
authorizationUrl: server.authorizationEndpoint,
tokenUrl: server.tokenEndpoint,
grant: "authorization_code",
clientId: "test-client",
clientSecret: "test-secret",
});
const started = yield* executor.oauth.start({
owner: "org",
client: CLIENT,
clientOwner: "org",
name: ConnectionName.make("main-account"),
integration: INTEG,
template: TEMPLATE,
});
expect(started.status).toBe("redirect");
if (started.status !== "redirect") return;
const callback = yield* server.completeAuthorizationCodeFlow({
authorizationUrl: started.authorizationUrl,
});

const completed = yield* executor.oauth
.complete({ state: started.state, code: callback.code }, { toolSync: "background" })
.pipe(Effect.timeoutOption("1 second"));
expect(
Option.isSome(completed),
"the callback returns while listTools remains deliberately blocked",
).toBe(true);
expect(keptAlive).toHaveLength(1);
yield* Deferred.await(discoveryStarted);

const connections = yield* executor.connections.list({ integration: INTEG });
expect(connections.map((connection) => String(connection.name))).toEqual(["mainAccount"]);

yield* Deferred.succeed(releaseDiscovery, undefined);
yield* Effect.promise(() => Promise.all(keptAlive));
const tools = yield* executor.tools.list({ integration: INTEG });
expect(tools.map((tool) => String(tool.name))).toEqual(["whoami"]);
}),
),
);

it.effect("carries the URL org selector in provider state without changing redirect_uri", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
type OAuthClientOrigin,
type OAuthClientSummary,
type OAuthCompleteInput,
type OAuthCompleteOptions,
type OAuthGrant,
type OAuthProbeInput,
type OAuthProbeResult,
Expand Down Expand Up @@ -124,6 +125,12 @@ export interface MintOAuthConnectionInput {
* code was redeemed at a region other than the client's configured token
* host (Datadog multi-site). Null means refresh uses the client's token URL. */
readonly oauthTokenUrl?: string | null;
/** Whether connection tool discovery must finish before the mint returns.
* Interactive authorization-code callbacks persist the fresh grant first,
* then synchronize the remote catalog in host-kept background work so a
* slow MCP server cannot strand the browser popup. Non-interactive grants
* keep the explicit behavior because their caller has no callback window. */
readonly toolSync?: "explicit" | "background";
}

/** Project an enterprise-managed mint failure onto the connect boundary,
Expand Down Expand Up @@ -1827,6 +1834,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
// -----------------------------------------------------------------------
const complete = (
input: OAuthCompleteInput,
options?: OAuthCompleteOptions,
): Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure> =>
Effect.gen(function* () {
const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) =>
Expand Down Expand Up @@ -1961,6 +1969,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
// Persist the regional token endpoint ONLY when it differs from the
// client's configured one, so refresh redeems against the same region.
tokenUrl === client.tokenUrl ? null : tokenUrl,
// The grant and connection row are the callback's durable contract.
// Remote catalog discovery can be arbitrarily slow and must not keep
// the popup waiting after that contract has committed.
options?.toolSync ?? "explicit",
).pipe(
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -2035,6 +2047,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
/** Regional token endpoint override to persist when the code was redeemed
* off the client's configured host; null to use the client's token URL. */
oauthTokenUrl: string | null,
toolSync: "explicit" | "background" = "explicit",
): Effect.Effect<Connection, StorageFailure> =>
Effect.gen(function* () {
const provider = deps.defaultWritableProvider();
Expand Down Expand Up @@ -2095,6 +2108,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
oauthScope,
missingOAuthScopes: missingScopes,
oauthTokenUrl,
toolSync,
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [
readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"];
readonly waitUntil?: ExecutorConfig<TPlugins>["waitUntil"];
};

export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
Expand Down Expand Up @@ -176,6 +177,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea
oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
firstPartyOAuthClients: options?.firstPartyOAuthClients,
enterpriseManagedRollout: options?.enterpriseManagedRollout,
waitUntil: options?.waitUntil,
};
};

Expand Down
Loading
Loading