Skip to content
10 changes: 5 additions & 5 deletions apps/cli/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,8 @@ const windowsTaskUserId = (): string => {
*
* - `boot: true` (requires an elevated/Administrator shell): a BootTrigger + an
* S4U principal at HighestAvailable. Runs the daemon AS THE USER at boot, with
* no stored password and no interactive logon survives a real reboot with no
* login on a headless host. Task Scheduler only lets an elevated shell create
* no stored password and no interactive logon. This survives a real reboot with
* no login on a headless host. Task Scheduler only lets an elevated shell create
* a boot/S4U task, which is why this path costs the UAC prompt.
*
* We register via `schtasks.exe` rather than the PowerShell `*-ScheduledTask`
Expand Down Expand Up @@ -668,7 +668,7 @@ export const generateWindowsHiddenLauncherVbs = (wrapperPath: string): string =>
const runSchtasks = (args: ReadonlyArray<string>): Effect.Effect<CommandResult, Error> =>
runCommand("schtasks.exe", args);

/** Write a UTF-16LE (BOM-prefixed) file the encoding schtasks expects for XML. */
/** Write a UTF-16LE (BOM-prefixed) file, the encoding schtasks expects for XML. */
const writeUtf16File = (
path: string,
contents: string,
Expand All @@ -680,7 +680,7 @@ const writeUtf16File = (
});

/**
* SCHED_S_TASK_RUNNING the Task Scheduler HRESULT a task reports as its "Last
* SCHED_S_TASK_RUNNING is the Task Scheduler HRESULT a task reports as its "Last
* Result" while it is currently running (0x00041301 == 267009). schtasks prints
* this in the verbose listing as a decimal; some Windows builds/locales print the
* hex form. The numeric code is locale-invariant even though the surrounding
Expand Down Expand Up @@ -810,7 +810,7 @@ const makeWindowsBackend = (): ServiceBackend => {
const detail = (create.stderr.trim() || create.stdout.trim()).replace(/\.\s*$/, "");
// The default (logon) task needs no elevation. Only the --boot path
// registers a boot/S4U task, which Task Scheduler refuses without
// Administrator so point access-denial at the relevant fix.
// Administrator, so point access-denial at the relevant fix.
const hint = /denied|0x80070005|administrator|elevat/i.test(detail)
? descriptor.boot
? " `--boot` registers a boot task, which needs an Administrator shell. Re-run elevated, or drop `--boot` to install a no-elevation login task."
Expand Down
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
20 changes: 17 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 @@ -99,6 +101,11 @@ const ConfigureServerPayload = Schema.Struct({

const ConfigureServerResponse = Schema.Struct({
config: McpIntegrationConfig,
toolsRefreshFailed: Schema.Boolean,
});

const RefreshServerToolsResponse = Schema.Struct({
toolsRefreshFailed: Schema.Boolean,
});

// The configureAuth payload/response — custom auth methods to merge-append
Expand Down Expand Up @@ -171,7 +178,14 @@ export const McpGroup = HttpApiGroup.make("mcp")
params: SlugParams,
payload: ConfigureServerPayload,
success: ConfigureServerResponse,
error: [InternalError, McpConnectionError, McpToolDiscoveryError],
error: [InternalError, McpConnectionError, McpToolDiscoveryError, IntegrationNotFound],
}),
)
.add(
HttpApiEndpoint.post("refreshServerTools", "/mcp/servers/:slug/tools/refresh", {
params: SlugParams,
success: RefreshServerToolsResponse,
error: [InternalError, McpConnectionError, McpToolDiscoveryError, IntegrationNotFound],
}),
)
.add(
Expand Down
39 changes: 39 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 All @@ -29,6 +30,7 @@ const failingExtension: McpPluginExtension = {
reconcileStdioConnections: () => unused,
getServer: () => Effect.succeed(null),
configureServer: () => unused,
refreshServerTools: () => unused,
configureAuth: () => unused,
};

Expand Down Expand Up @@ -69,6 +71,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 +126,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");
}),
);
});
11 changes: 9 additions & 2 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,15 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand
capture(
Effect.gen(function* () {
const ext = yield* McpExtensionService;
yield* ext.configureServer(path.slug, payload.config);
return { config: payload.config };
return yield* ext.configureServer(path.slug, payload.config);
}),
),
)
.handle("refreshServerTools", ({ params: path }) =>
capture(
Effect.gen(function* () {
const ext = yield* McpExtensionService;
return yield* ext.refreshServerTools(path.slug);
}),
),
)
Expand Down
65 changes: 41 additions & 24 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,12 @@ 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,
stdioArgsToText,
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 +63,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 @@ -183,9 +176,10 @@ export default function AddMcpSource(props: {
// --- Stdio state ---
const [stdioCommand, setStdioCommand] = useState(isStdioPreset ? preset.command : "");
const [stdioArgs, setStdioArgs] = useState(
isStdioPreset && preset.args ? preset.args.join(" ") : "",
isStdioPreset && preset.args ? stdioArgsToText(preset.args) : "",
);
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 +346,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 +375,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 +509,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