Skip to content
Merged
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
225 changes: 225 additions & 0 deletions apps/local/src/server/config-sync.boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// Boot-time replay of MCP auth from `executor.jsonc`. The plugin already
// resolves `secret-public-ref:` strings at connect time via
// `ctx.secrets.get` — the regression class here is auth being silently
// dropped at the config boundary. `mcp.addSource` persists the source
// row even when the remote is unreachable, so unreachable endpoints are
// enough to assert on the stored auth shape without running an MCP
// server. The new credential-binding flow does require referenced
// secrets/connections to exist at the target scope, hence the seeds
// before each test.

import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor-js/config";
import {
ConnectionId,
CreateConnectionInput,
OAUTH2_PROVIDER_KEY,
ScopeId,
SecretId,
TokenMaterial,
createExecutor,
definePlugin,
makeTestConfig,
type SecretProvider,
} from "@executor-js/sdk";
import { mcpPlugin } from "@executor-js/plugin-mcp";

import { syncFromConfig } from "./config-sync";
import type { LocalExecutor } from "./executor";

const UNREACHABLE = "http://127.0.0.1:1/mcp";
const TEST_SCOPE = ScopeId.make("test-scope");

const makeMemorySecretsPlugin = () => {
const store = new Map<string, string>();
const provider: SecretProvider = {
key: "memory",
writable: true,
get: (id, scope) => Effect.sync(() => store.get(`${scope} ${id}`) ?? null),
set: (id, value, scope) =>
Effect.sync(() => {
store.set(`${scope} ${id}`, value);
}),
delete: (id, scope) => Effect.sync(() => store.delete(`${scope} ${id}`)),
list: () =>
Effect.sync(() =>
Array.from(store.keys()).map((k) => {
const id = k.split(" ", 2)[1] ?? k;
return { id, name: id };
}),
),
};
return definePlugin(() => ({
id: "memory-secrets" as const,
storage: () => ({}),
secretProviders: [provider],
}));
};

let workDir: string;

beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-"));
});

afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});

const writeConfig = (config: ExecutorFileConfig): string => {
const path = join(workDir, "executor.jsonc");
writeFileSync(path, JSON.stringify(config, null, 2));
return path;
};

const makeExecutor = () =>
createExecutor(makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }));

describe("syncFromConfig — MCP auth replay", () => {
it.effect("strips secret-public-ref prefix from header auth", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "PostHog",
endpoint: UNREACHABLE,
namespace: "posthog",
auth: {
kind: "header",
headerName: "Authorization",
secret: `${SECRET_REF_PREFIX}posthog-api-key`,
prefix: "Bearer ",
},
},
],
});
const executor = yield* makeExecutor();
yield* executor.secrets.set({
id: SecretId.make("posthog-api-key"),
scope: TEST_SCOPE,
name: "PostHog API Key",
value: "phx_test_token",
provider: "memory",
});

yield* syncFromConfig({
// The test executor uses a narrower plugin tuple than LocalExecutor (no
// openapi/graphql), but Match in addSourceFromConfig only dispatches to
// the mcp branch for these fixtures, so the missing methods are never
// touched at runtime.
// oxlint-disable-next-line executor/no-double-cast
executor: executor as unknown as LocalExecutor,
configPath,
targetScope: TEST_SCOPE,
});

const stored = yield* executor.mcp.getSource("posthog", TEST_SCOPE);
expect(stored).not.toBeNull();
expect(stored!.config).toMatchObject({
transport: "remote",
endpoint: UNREACHABLE,
auth: {
kind: "header",
headerName: "Authorization",
secretSlot: "auth:header",
prefix: "Bearer ",
},
});
}),
);

it.effect("passes oauth2 auth through unchanged", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "Linear",
endpoint: UNREACHABLE,
namespace: "linear",
auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" },
},
],
});
const executor = yield* makeExecutor();
const connectionId = ConnectionId.make("mcp-oauth2-linear");
yield* executor.connections.create(
new CreateConnectionInput({
id: connectionId,
scope: TEST_SCOPE,
provider: OAUTH2_PROVIDER_KEY,
identityLabel: "[email protected]",
accessToken: new TokenMaterial({
secretId: SecretId.make(`${connectionId}.access_token`),
name: "MCP Access Token",
value: "access-token-value",
}),
refreshToken: null,
expiresAt: null,
oauthScope: null,
providerState: {
endpoint: UNREACHABLE,
tokenType: "Bearer",
clientInformation: { client_id: "fake" },
authorizationServerUrl: null,
authorizationServerMetadata: null,
resourceMetadataUrl: null,
resourceMetadata: null,
},
}),
);

yield* syncFromConfig({
// oxlint-disable-next-line executor/no-double-cast
executor: executor as unknown as LocalExecutor,
configPath,
targetScope: TEST_SCOPE,
});

const stored = yield* executor.mcp.getSource("linear", TEST_SCOPE);
expect(stored!.config).toMatchObject({
transport: "remote",
auth: { kind: "oauth2", connectionSlot: "auth:oauth2:connection" },
});
}),
);

it.effect("preserves kind:none auth on replay", () =>
Effect.gen(function* () {
const configPath = writeConfig({
sources: [
{
kind: "mcp",
transport: "remote",
name: "DeepWiki",
endpoint: UNREACHABLE,
namespace: "devin",
auth: { kind: "none" },
},
],
});
const executor = yield* makeExecutor();

yield* syncFromConfig({
// oxlint-disable-next-line executor/no-double-cast
executor: executor as unknown as LocalExecutor,
configPath,
targetScope: TEST_SCOPE,
});

const stored = yield* executor.mcp.getSource("devin", TEST_SCOPE);
expect(stored!.config).toMatchObject({
transport: "remote",
auth: { kind: "none" },
});
}),
);
});
3 changes: 3 additions & 0 deletions apps/local/src/server/config-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const addSourceFromConfig = (
baseUrl: s.baseUrl,
namespace: s.namespace,
headers: translateHeaders(s.headers),
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid),
),
Expand All @@ -128,6 +129,7 @@ const addSourceFromConfig = (
scope: targetScope,
namespace: s.namespace,
headers: translateHeaders(s.headers) as Record<string, string> | undefined,
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid),
),
Expand Down Expand Up @@ -157,6 +159,7 @@ const addSourceFromConfig = (
headers: s.headers,
namespace: s.namespace,
auth: translateMcpAuth(s.auth),
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid);
}),
Expand Down
82 changes: 82 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,88 @@ describe("mcpPlugin", () => {
}),
);

// -------------------------------------------------------------------------
// updateSource must persist auth changes to the config file too —
// otherwise the next boot replays the file's stale auth and silently
// overwrites the DB. Symmetric with addSource/removeSource which
// already write through.
// -------------------------------------------------------------------------

it.effect("updateSource writes auth changes back to the config file", () =>
Effect.gen(function* () {
const calls: Array<{ op: "upsert" | "remove"; payload: unknown }> = [];
const stubSink = {
upsertSource: (source: unknown) =>
Effect.sync(() => {
calls.push({ op: "upsert", payload: source });
}),
removeSource: (namespace: string) =>
Effect.sync(() => {
calls.push({ op: "remove", payload: namespace });
}),
};

const executor = yield* createExecutor(
makeTestConfig({
plugins: [makeMemorySecretsPlugin()(), mcpPlugin({ configFile: stubSink })] as const,
}),
);

for (const id of ["sentry-token-old", "sentry-token-new"]) {
yield* executor.secrets.set({
id: SecretId.make(id),
scope: ScopeId.make("test-scope"),
name: id,
value: `value-${id}`,
provider: "memory",
});
}

yield* executor.mcp
.addSource({
transport: "remote",
scope: "test-scope",
name: "Sentry",
endpoint: "http://127.0.0.1:1/sentry-mcp",
namespace: "sentry",
credentialTargetScope: "test-scope",
auth: {
kind: "header",
headerName: "Authorization",
secretId: "sentry-token-old",
prefix: "Bearer ",
},
})
.pipe(Effect.result);

calls.length = 0; // ignore the addSource upsert; we're asserting on update

yield* executor.mcp.updateSource("sentry", "test-scope", {
credentialTargetScope: ScopeId.make("test-scope"),
auth: {
kind: "header",
headerName: "Authorization",
secretId: "sentry-token-new",
prefix: "Bearer ",
},
});

const upserts = calls.filter((c) => c.op === "upsert");
expect(upserts).toHaveLength(1);
expect(upserts[0]!.payload).toMatchObject({
kind: "mcp",
transport: "remote",
namespace: "sentry",
auth: {
kind: "header",
headerName: "Authorization",
secret: "secret-public-ref:sentry-token-new",
prefix: "Bearer ",
},
});
}),
);

// -------------------------------------------------------------------------
// Deferred OAuth — admin saves a source with `{kind: "oauth2",
// connectionId}` before any user has signed in, so the row lands in
Expand Down
Loading
Loading