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
Binary file added apps/local/src/server/config-sync.test.ts
Binary file not shown.
33 changes: 32 additions & 1 deletion apps/local/src/server/config-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import { join } from "node:path";
import * as fs from "node:fs";
import * as jsonc from "jsonc-parser";

import type { SourceConfig, ExecutorFileConfig, ConfigHeaderValue } from "@executor-js/config";
import type {
SourceConfig,
ExecutorFileConfig,
ConfigHeaderValue,
McpAuthConfig,
} from "@executor-js/config";
import { SECRET_REF_PREFIX } from "@executor-js/config";
import type { ScopeId } from "@executor-js/sdk";
import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp";

import type { LocalExecutor } from "./executor";

Expand Down Expand Up @@ -50,6 +56,27 @@ const translateHeaders = (
return out;
};

// ---------------------------------------------------------------------------
// MCP auth translation: file format → addSource input format
//
// File `auth.secret` is `secret-public-ref:<id>`; the plugin's addSource
// accepts the legacy `{kind: "header", secretId, ...}` shape via
// `McpConnectionAuthInput` and internally creates the matching
// credential_binding row. `none` and `oauth2` (which carries `connectionId`)
// are also valid `McpConnectionAuthInput` variants.
// ---------------------------------------------------------------------------

const mcpAuthFromConfig = (
auth: McpAuthConfig | undefined,
): McpConnectionAuthInput | undefined => {
if (!auth) return undefined;
if (auth.kind !== "header") return auth;
const secretId = auth.secret.startsWith(SECRET_REF_PREFIX)
? auth.secret.slice(SECRET_REF_PREFIX.length)
: auth.secret;
return { kind: "header", headerName: auth.headerName, secretId, prefix: auth.prefix };
};

// ---------------------------------------------------------------------------
// Config path resolution
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -90,6 +117,7 @@ const addSourceFromConfig = (
baseUrl: source.baseUrl,
namespace: source.namespace,
headers: translateHeaders(source.headers),
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid);

Expand All @@ -100,6 +128,7 @@ const addSourceFromConfig = (
scope: targetScope,
namespace: source.namespace,
headers: translateHeaders(source.headers) as Record<string, string> | undefined,
credentialTargetScope: targetScope,
})
.pipe(Effect.asVoid);

Expand Down Expand Up @@ -127,7 +156,9 @@ const addSourceFromConfig = (
remoteTransport: source.remoteTransport,
queryParams: source.queryParams,
headers: source.headers,
auth: mcpAuthFromConfig(source.auth),
namespace: source.namespace,
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
118 changes: 117 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ConfiguredCredentialBinding,
ConnectionId,
type CredentialBindingRef,
type CredentialBindingValue,
ScopeId,
SecretId,
SourceDetectionResult,
Expand Down Expand Up @@ -912,6 +913,102 @@ const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig |
};
};

// ---------------------------------------------------------------------------
// Storage-form → input-form reconstruction
//
// `toMcpConfigEntry` consumes the `McpSourceConfig` *input* shape — the
// legacy form with `secretId` / `connectionId`, which `authToConfig` and
// `plainStringMap` know how to render into the file. Stored remote data
// is in slot form (`secretSlot`, `{kind: "binding", slot}`), so writing
// the file from a stored row needs the slot → secret/connection lookups
// realized first. Walk the source's `credential_binding` rows and rebuild
// the input shape; any slot whose binding is missing is dropped.
// ---------------------------------------------------------------------------

const toCredentialInput = (
bySlot: Map<string, CredentialBindingValue>,
configured: ConfiguredMcpCredentialValue,
): McpCredentialInput | undefined => {
if (typeof configured === "string") return configured;
const value = bySlot.get(configured.slot);
if (!value) return undefined;
if (value.kind === "secret") {
return {
secretId: value.secretId,
...(configured.prefix ? { prefix: configured.prefix } : {}),
};
}
if (value.kind === "text") return value.text;
// headers / queryParams cannot reference connections — only auth can.
return undefined;
};

const toCredentialInputMap = (
bySlot: Map<string, CredentialBindingValue>,
values: Record<string, ConfiguredMcpCredentialValue> | undefined,
): Record<string, McpCredentialInput> | undefined => {
if (!values) return undefined;
const out: Record<string, McpCredentialInput> = {};
for (const [name, configured] of Object.entries(values)) {
const input = toCredentialInput(bySlot, configured);
if (input !== undefined) out[name] = input;
}
return Object.keys(out).length > 0 ? out : undefined;
};

const toAuthInput = (
bySlot: Map<string, CredentialBindingValue>,
auth: McpConnectionAuth,
): McpConnectionAuthInput | undefined => {
if (auth.kind === "none") return { kind: "none" };
if (auth.kind === "header") {
const value = bySlot.get(auth.secretSlot);
if (value?.kind !== "secret") return undefined;
return {
kind: "header",
headerName: auth.headerName,
secretId: value.secretId,
prefix: auth.prefix,
};
}
const connection = bySlot.get(auth.connectionSlot);
if (connection?.kind !== "connection") return undefined;
return { kind: "oauth2", connectionId: connection.connectionId };
};

const inputFormFromStored = (
bindings: ReadonlyArray<CredentialBindingRef>,
stored: McpStoredSourceData,
scope: string,
sourceName: string,
namespace: string,
): McpSourceConfig => {
if (stored.transport === "stdio") {
return {
transport: "stdio",
scope,
name: sourceName,
namespace,
command: stored.command,
args: stored.args ? [...stored.args] : undefined,
env: stored.env,
cwd: stored.cwd,
};
}
const bySlot = new Map(bindings.map((b) => [b.slotKey, b.value] as const));
return {
transport: "remote",
scope,
name: sourceName,
namespace,
endpoint: stored.endpoint,
remoteTransport: stored.remoteTransport,
headers: toCredentialInputMap(bySlot, stored.headers),
queryParams: toCredentialInputMap(bySlot, stored.queryParams),
auth: toAuthInput(bySlot, stored.auth),
};
};

const toMcpConfigEntry = (
namespace: string,
sourceName: string,
Expand Down Expand Up @@ -1455,6 +1552,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
...(canonicalAuth ? { auth: canonicalAuth.auth } : {}),
...(canonicalQueryParams ? { queryParams: canonicalQueryParams.values } : {}),
};
const sourceName = input.name?.trim() || existing.name;

const affectedPrefixes = [
...(input.headers !== undefined ? ["header:"] : []),
Expand All @@ -1467,7 +1565,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
yield* ctx.storage.putSource({
namespace,
scope,
name: input.name?.trim() || existing.name,
name: sourceName,
config: updatedConfig,
});
if (affectedPrefixes.length > 0 || directBindings.length > 0) {
Expand All @@ -1485,6 +1583,24 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
}
}),
);

if (configFile) {
const bindings = yield* ctx.credentialBindings.listForSource({
pluginId: MCP_PLUGIN_ID,
sourceId: namespace,
sourceScope: ScopeId.make(scope),
});
const inputForm = inputFormFromStored(
bindings,
updatedConfig,
scope,
sourceName,
namespace,
);
yield* configFile
.upsertSource(toMcpConfigEntry(namespace, sourceName, inputForm))
.pipe(Effect.withSpan("mcp.plugin.config_file.upsert"));
}
}).pipe(
Effect.withSpan("mcp.plugin.update_source", {
attributes: { "mcp.source.namespace": namespace },
Expand Down
Loading