Skip to content
1 change: 1 addition & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3308,6 +3308,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
remove: (ref) => connectionsRemove(ref),
refresh: (ref) => connectionsRefresh(ref),
resolveValue: (ref) => resolveConnectionValueByRef(ref),
resolveValues: (ref) => resolveConnectionValuesByRef(ref),
},
providers: {
list: () => providersList(),
Expand Down
6 changes: 5 additions & 1 deletion packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,13 @@ export interface PluginCtx<TStore = unknown> {
readonly Tool[],
ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure
>;
/** Resolve a connection's value through its provider (and OAuth refresh).
/** Resolve a connection's primary value through its provider (and OAuth refresh).
* null if the provider can't produce one. */
readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>;
/** Resolve every named input for a connection through its provider. */
readonly resolveValues: (
ref: ConnectionRef,
) => Effect.Effect<Record<string, string | null>, StorageFailure>;
};

/** Registered credential backends — for discovery (browse a backend's items). */
Expand Down
8 changes: 5 additions & 3 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IntegrationSlug,
InternalError,
IntegrationAlreadyExistsError,
IntegrationNotFoundError,
} from "@executor-js/sdk/shared";

import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors";
Expand All @@ -21,6 +22,7 @@ import {
const SlugParams = { slug: IntegrationSlug };

const StringMap = Schema.Record(Schema.String, Schema.String);
const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });

// ---------------------------------------------------------------------------
// Add server — discriminated union on transport. An MCP server is registered
Expand Down Expand Up @@ -52,9 +54,9 @@ const AddStdioServerPayload = Schema.Struct({
command: Schema.String,
args: Schema.optional(Schema.Array(Schema.String)),
/** Declare the secret env vars this server needs, by name. Their values are
* supplied as the connection's secrets (the connect step), not here. */
* supplied as the connection's secrets. */
envVars: Schema.optional(Schema.Array(Schema.String)),
/** One-shot secret env values (programmatic). The UI sends `envVars`. */
/** Static, non-credential environment variables injected into the subprocess. */
env: Schema.optional(StringMap),
cwd: Schema.optional(Schema.String),
slug: Schema.optional(Schema.String),
Expand Down Expand Up @@ -171,7 +173,7 @@ export const McpGroup = HttpApiGroup.make("mcp")
params: SlugParams,
payload: ConfigureServerPayload,
success: ConfigureServerResponse,
error: [InternalError, McpConnectionError, McpToolDiscoveryError],
error: [InternalError, McpConnectionError, McpToolDiscoveryError, IntegrationNotFound],
}),
)
.add(
Expand Down
38 changes: 38 additions & 0 deletions packages/plugins/mcp/src/api/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Effect, Layer, Schema } from "effect";

import { addGroup, observabilityMiddleware } from "@executor-js/api";
import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server";
import { IntegrationNotFoundError, IntegrationSlug } from "@executor-js/sdk/shared";
import type { McpPluginExtension } from "../sdk/plugin";
import { McpConnectionError } from "../sdk/errors";
import { McpExtensionService, McpHandlers } from "./handlers";
Expand Down Expand Up @@ -69,6 +70,11 @@ const McpConnectionErrorResponse = Schema.Struct({
message: Schema.String,
});

const IntegrationNotFoundErrorResponse = Schema.Struct({
_tag: Schema.Literal("IntegrationNotFoundError"),
slug: Schema.String,
});

describe("McpHandlers", () => {
it.effect("defect-returning methods produce an opaque InternalError, no leakage", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -119,4 +125,36 @@ describe("McpHandlers", () => {
expect(body.message).toContain("Do you need to provide an API key");
}),
);

it.effect("configureServer IntegrationNotFoundError is encoded as a 404 response", () =>
Effect.gen(function* () {
const slug = IntegrationSlug.make("missing_mcp");
const web = yield* webHandlerFor({
...failingExtension,
configureServer: () => Effect.fail(new IntegrationNotFoundError({ slug })),
});
const response = yield* Effect.promise(() =>
(web.handler as (request: Request) => Promise<Response>)(
new Request("http://localhost/mcp/servers/missing_mcp/config", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
config: {
transport: "remote",
endpoint: "https://example.com/mcp",
remoteTransport: "auto",
authenticationTemplate: [{ slug: "none", kind: "none" }],
},
}),
}),
),
);

expect(response.status).toBe(404);
const body = yield* Schema.decodeUnknownEffect(IntegrationNotFoundErrorResponse)(
yield* Effect.promise(() => response.json()),
);
expect(body.slug).toBe("missing_mcp");
}),
);
});
58 changes: 35 additions & 23 deletions packages/plugins/mcp/src/react/AddMcpSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "@executor-js/react/components/card-stack";
import { FloatActions } from "@executor-js/react/components/float-actions";
import { Input } from "@executor-js/react/components/input";
import { TagInput } from "@executor-js/react/components/tag-input";
import { Textarea } from "@executor-js/react/components/textarea";
import {
integrationDisplayNameFromStdio,
integrationDisplayNameFromUrl,
Expand All @@ -39,6 +39,7 @@ import { probeMcpEndpoint, addMcpServer } from "./atoms";
import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
import { mcpPresets, type McpPreset } from "../sdk/presets";
import { parseStdioArgs, parseStdioEnv, stdioEnvParseErrorMessage } from "../sdk/stdio-config";

// The remote add flow REGISTERS the server's declared auth methods through the
// shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in)
Expand All @@ -57,19 +58,6 @@ function findPreset(id: string | undefined): McpPreset | undefined {
return mcpPresets.find((p) => p.id === id);
}

// Splits the raw args field into tokens, honoring double-quoted groups so an
// argument with spaces stays intact.
function parseStdioArgs(raw: string): string[] {
if (!raw.trim()) return [];
const args: string[] = [];
const regex = /[^\s"]+|"([^"]*)"/g;
let match;
while ((match = regex.exec(raw)) !== null) {
args.push(match[1] ?? match[0]);
}
return args;
}

// ---------------------------------------------------------------------------
// State machine (remote flow)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -185,7 +173,8 @@ export default function AddMcpSource(props: {
const [stdioArgs, setStdioArgs] = useState(
isStdioPreset && preset.args ? preset.args.join(" ") : "",
);
const [stdioEnvVars, setStdioEnvVars] = useState<string[]>([]);
const [stdioEnvText, setStdioEnvText] = useState("");
const [stdioCwd, setStdioCwd] = useState("");
const stdioIdentity = useIntegrationIdentity({
fallbackName: isStdioPreset
? preset.name
Expand Down Expand Up @@ -352,18 +341,26 @@ export default function AddMcpSource(props: {
const handleAddStdio = useCallback(async () => {
const cmd = stdioCommand.trim();
if (!cmd) return;
const parsedEnv = parseStdioEnv(stdioEnvText);
if (!parsedEnv.ok) {
setStdioError(stdioEnvParseErrorMessage(parsedEnv.error));
return;
}
setStdioAdding(true);
setStdioError(null);
const displayName = stdioIdentity.name.trim() || cmd;
const slug = slugifyNamespace(stdioIdentity.namespace) || undefined;
const parsedArgs = parseStdioArgs(stdioArgs);
const trimmedCwd = stdioCwd.trim();
const exit = await doAddServer({
payload: {
transport: "stdio" as const,
name: displayName,
...(slug ? { slug } : {}),
command: cmd,
args: parseStdioArgs(stdioArgs),
envVars: stdioEnvVars.length > 0 ? stdioEnvVars : undefined,
...(parsedArgs.length > 0 ? { args: parsedArgs } : {}),
...(parsedEnv.env !== undefined ? { env: parsedEnv.env } : {}),
...(trimmedCwd.length > 0 ? { cwd: trimmedCwd } : {}),
},
reactivityKeys: integrationWriteKeys,
});
Expand All @@ -373,7 +370,7 @@ export default function AddMcpSource(props: {
return;
}
props.onComplete(exit.value.slug);
}, [stdioCommand, stdioArgs, stdioEnvVars, stdioIdentity, doAddServer, props]);
}, [stdioCommand, stdioArgs, stdioEnvText, stdioCwd, stdioIdentity, doAddServer, props]);

// ---- Render ----

Expand Down Expand Up @@ -507,14 +504,29 @@ export default function AddMcpSource(props: {
/>
</CardStackEntryField>

<CardStackEntryField
label="Working directory"
description="Optional directory to run the command from."
>
<Input
value={stdioCwd}
onChange={(e) => setStdioCwd((e.target as HTMLInputElement).value)}
placeholder="/path/to/project"
className="font-mono text-sm"
data-ph-block
/>
</CardStackEntryField>

<CardStackEntryField
label="Environment variables"
description="- Names only; secret values are entered when you connect."
description="Additional variables, one KEY=value per line."
>
<TagInput
values={stdioEnvVars}
onChange={setStdioEnvVars}
placeholder="Add an env var, e.g. GITHUB_TOKEN"
<Textarea
value={stdioEnvText}
onChange={(e) => setStdioEnvText((e.target as HTMLTextAreaElement).value)}
placeholder={"DEBUG=true\nAPI_BASE_URL=https://example.com"}
className="font-mono text-sm"
data-ph-block
/>
</CardStackEntryField>
</CardStackContent>
Expand Down
Loading
Loading