Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ const UpdateSourcePayload = Schema.Struct({
queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)),
credentialTargetScope: Schema.optional(ScopeId),
auth: Schema.optional(AuthPayload),
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
env: Schema.optional(StringMap),
cwd: Schema.optional(Schema.String),
});

const UpdateSourceResponse = Schema.Struct({
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand
queryParams: payload.queryParams,
credentialTargetScope: payload.credentialTargetScope,
auth: payload.auth as McpUpdateSourceInput["auth"],
command: payload.command,
args: payload.args,
env: payload.env,
cwd: payload.cwd,
});
return { updated: true };
}),
Expand Down
120 changes: 14 additions & 106 deletions packages/plugins/mcp/src/react/AddMcpSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,21 @@ import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode }
import { useAtomSet } from "@effect/atom-react";
import * as Exit from "effect/Exit";
import * as Match from "effect/Match";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import { messageFromExit } from "@executor-js/react/api/error-reporting";
import { useScope } from "@executor-js/react/api/scope-context";
import { Button } from "@executor-js/react/components/button";
import {
CardStack,
CardStackContent,
CardStackEntry,
CardStackEntryField,
} from "@executor-js/react/components/card-stack";
import { FieldLabel } from "@executor-js/react/components/field";
import { FilterTabs } from "@executor-js/react/components/filter-tabs";
import { FloatActions } from "@executor-js/react/components/float-actions";
import { Input } from "@executor-js/react/components/input";
import { Label } from "@executor-js/react/components/label";
import { Spinner } from "@executor-js/react/components/spinner";
import { Textarea } from "@executor-js/react/components/textarea";
import {
emptyHttpCredentials,
httpCredentialsValid,
Expand Down Expand Up @@ -50,25 +47,11 @@ type RemoteAuthMode = "none" | "oauth2";
import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
import { McpStdioSourceFields } from "./McpStdioSourceFields";
import { parseStdioArgs, parseStdioEnv } from "./stdio-form-helpers";
import { mcpPresets, type McpPreset } from "../sdk/presets";
import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";

const ErrorMessage = Schema.Struct({ message: Schema.String });
const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
"\\": "\\",
n: "\n",
r: "\r",
t: "\t",
'"': '"',
};

const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
onNone: () => fallback,
onSome: ({ message }) => message,
});

// ---------------------------------------------------------------------------
// Preset lookup
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -361,7 +344,7 @@ export default function AddMcpSource(props: {
if (Exit.isFailure(exit)) {
dispatch({
type: "probe-fail",
error: errorMessageFromExit(exit, "Failed to connect"),
error: messageFromExit(exit, "Failed to connect"),
});
return;
}
Expand Down Expand Up @@ -487,7 +470,7 @@ export default function AddMcpSource(props: {
if (Exit.isFailure(exit)) {
dispatch({
type: "add-fail",
error: errorMessageFromExit(exit, "Failed to add source"),
error: messageFromExit(exit, "Failed to add source"),
});
return;
}
Expand All @@ -509,47 +492,6 @@ export default function AddMcpSource(props: {

// ---- Stdio actions ----

const 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;
};

const parseStdioEnvValue = (raw: string): string => {
const value = raw.trim();
if (value.length < 2) return value;

const quote = value[0];
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) {
return value;
}

const inner = value.slice(1, -1);
if (quote === "'") return inner;

return inner.replace(
/\\([\\nrt"])/g,
(_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped,
);
};

const parseStdioEnv = (raw: string): Record<string, string> | undefined => {
if (!raw.trim()) return undefined;
const env: Record<string, string> = {};
for (const line of raw.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) {
env[line.slice(0, eq).trim()] = parseStdioEnvValue(line.slice(eq + 1));
}
}
return Object.keys(env).length > 0 ? env : undefined;
};

const handleAddStdio = useCallback(async () => {
const cmd = stdioCommand.trim();
if (!cmd) return;
Expand All @@ -571,7 +513,7 @@ export default function AddMcpSource(props: {
reactivityKeys: sourceWriteKeys,
});
if (Exit.isFailure(exit)) {
setStdioError(errorMessageFromExit(exit, "Failed to add source"));
setStdioError(messageFromExit(exit, "Failed to add source"));
setStdioAdding(false);
return;
}
Expand Down Expand Up @@ -894,48 +836,14 @@ export default function AddMcpSource(props: {
</>
) : (
<>
{/* Stdio form */}
<CardStack>
<CardStackContent className="border-t-0">
<CardStackEntryField
label="Command"
description="- The executable to run (e.g. npx, uvx, node)."
>
<Input
value={stdioCommand}
onChange={(e) => setStdioCommand((e.target as HTMLInputElement).value)}
placeholder="npx"
className="font-mono text-sm"
/>
</CardStackEntryField>

<CardStackEntryField
label="Arguments"
description="- Space-separated arguments passed to the command."
>
<Input
value={stdioArgs}
onChange={(e) => setStdioArgs((e.target as HTMLInputElement).value)}
placeholder="-y chrome-devtools-mcp@latest"
className="font-mono text-sm"
/>
</CardStackEntryField>

<CardStackEntryField
label="Environment variables"
description="- One per line, KEY=value format."
>
<Textarea
value={stdioEnv}
onChange={(e) => setStdioEnv((e.target as HTMLTextAreaElement).value)}
placeholder={"KEY=value\nANOTHER=value"}
rows={3}
maxRows={10}
className="font-mono text-sm"
/>
</CardStackEntryField>
</CardStackContent>
</CardStack>
<McpStdioSourceFields
command={stdioCommand}
onCommandChange={setStdioCommand}
args={stdioArgs}
onArgsChange={setStdioArgs}
env={stdioEnv}
onEnvChange={setStdioEnv}
/>

<SourceIdentityFields identity={stdioIdentity} namePlaceholder="My MCP Server" />

Expand Down
95 changes: 84 additions & 11 deletions packages/plugins/mcp/src/react/EditMcpSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
updateMcpSource,
} from "./atoms";
import { connectionsAtom } from "@executor-js/react/api/atoms";
import { messageFromExit } from "@executor-js/react/api/error-reporting";
import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
Expand All @@ -30,6 +31,13 @@ import { Button } from "@executor-js/react/components/button";
import { Badge } from "@executor-js/react/components/badge";
import { ScopeId } from "@executor-js/sdk/shared";
import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
import { McpStdioSourceFields } from "./McpStdioSourceFields";
import {
formatStdioArgs,
formatStdioEnv,
parseStdioArgs,
parseStdioEnv,
} from "./stdio-form-helpers";
import {
McpSourceBindingInput,
type McpCredentialInput,
Expand Down Expand Up @@ -240,39 +248,104 @@ function RemoteEditForm(props: {
}

// ---------------------------------------------------------------------------
// Stdio read-only view
// Stdio edit form
// ---------------------------------------------------------------------------

function StdioReadOnly(props: {
function StdioEditForm(props: {
sourceId: string;
initial: McpStoredSourceSchemaType & { config: { transport: "stdio" } };
onSave: () => void;
}) {
const { command, args } = props.initial.config;
const displayScope = useScope();
const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" });
const sourceScope = ScopeId.make(props.initial.scope);

const initialArgs = formatStdioArgs(props.initial.config.args);
const initialEnv = formatStdioEnv(props.initial.config.env);
const initialCwd = props.initial.config.cwd ?? "";

const [command, setCommand] = useState(props.initial.config.command);
const [args, setArgs] = useState(initialArgs);
const [env, setEnv] = useState(initialEnv);
const [cwd, setCwd] = useState(initialCwd);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const trimmedCommand = command.trim();
const commandChanged = trimmedCommand !== props.initial.config.command.trim();
const argsChanged = args !== initialArgs;
const envChanged = env !== initialEnv;
const cwdChanged = cwd !== initialCwd;
const dirty = commandChanged || argsChanged || envChanged || cwdChanged;
const canSave = Boolean(trimmedCommand) && dirty && !saving;

const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
const exit = await doUpdate({
params: { scopeId: displayScope, namespace: props.sourceId },
payload: {
sourceScope,
...(commandChanged ? { command: trimmedCommand } : {}),
...(argsChanged ? { args: parseStdioArgs(args) } : {}),
...(envChanged ? { env: parseStdioEnv(env) ?? {} } : {}),
...(cwdChanged ? { cwd: cwd.trim() } : {}),
},
reactivityKeys: sourceWriteKeys,
});
if (Exit.isFailure(exit)) {
setError(messageFromExit(exit, "Failed to update source"));
setSaving(false);
return;
}
setSaving(false);
props.onSave();
};

return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
<p className="mt-1 text-sm text-muted-foreground">
Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the
updated command.
Update the command, arguments, and environment for this MCP server. Saving re-discovers
the available tools.
</p>
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-card-foreground">{props.sourceId}</p>
<p className="mt-0.5 text-xs text-muted-foreground font-mono">
{command} {(args ?? []).join(" ")}
</p>
</div>
<Badge variant="secondary" className="text-xs">
stdio
</Badge>
</div>

<div className="flex items-center justify-end border-t border-border pt-4">
<Button onClick={props.onSave}>Done</Button>
<McpStdioSourceFields
command={command}
onCommandChange={setCommand}
args={args}
onArgsChange={setArgs}
env={env}
onEnvChange={setEnv}
cwd={cwd}
onCwdChange={setCwd}
/>

{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<p className="text-sm text-destructive">{error}</p>
</div>
)}

<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="ghost" onClick={props.onSave} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
);
Expand Down Expand Up @@ -312,7 +385,7 @@ export default function EditMcpSource({

if (source.config.transport === "stdio") {
return (
<StdioReadOnly
<StdioEditForm
sourceId={sourceId}
initial={source as McpStoredSourceSchemaType & { config: { transport: "stdio" } }}
onSave={onSave}
Expand Down
Loading