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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"@effect/platform": "catalog:",
"@effect/platform-node": "catalog:",
"@executor/config": "workspace:*",
"@executor/sdk": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"effect": "catalog:"
Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
import { Schema } from "effect";
import { ScopeId } from "@executor/sdk";

import { McpStoredSourceSchema } from "../sdk/stored-source";

// Re-export for handler use
export { HttpApiSchema };

Expand All @@ -10,6 +12,7 @@ export { HttpApiSchema };
// ---------------------------------------------------------------------------

const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
const namespaceParam = HttpApiSchema.param("namespace", Schema.String);

// ---------------------------------------------------------------------------
// Auth payload (only for remote)
Expand Down Expand Up @@ -71,6 +74,17 @@ const AddSourcePayload = Schema.Union(
// Other payloads
// ---------------------------------------------------------------------------

const UpdateSourcePayload = Schema.Struct({
endpoint: Schema.optional(Schema.String),
headers: Schema.optional(StringMap),
queryParams: Schema.optional(StringMap),
auth: Schema.optional(AuthPayload),
});

const UpdateSourceResponse = Schema.Struct({
updated: Schema.Boolean,
});

const ProbeEndpointPayload = Schema.Struct({
endpoint: Schema.String,
});
Expand Down Expand Up @@ -194,4 +208,15 @@ export class McpGroup extends HttpApiGroup.make("mcp")
.addSuccess(HtmlResponse)
.addError(McpApiError),
)
.add(
HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
.addSuccess(Schema.NullOr(McpStoredSourceSchema))
.addError(McpApiError),
)
.add(
HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
.setPayload(UpdateSourcePayload)
.addSuccess(UpdateSourceResponse)
.addError(McpApiError),
)
{}
20 changes: 19 additions & 1 deletion packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
import { Context, Effect } from "effect";

import { addGroup } from "@executor/api";
import type { McpPluginExtension, McpSourceConfig } from "../sdk/plugin";
import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
import { McpGroup } from "./group";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -196,6 +196,24 @@ export const McpHandlers = HttpApiBuilder.group(
});
}).pipe(Effect.orDie),
)
.handle("getSource", ({ path }) =>
Effect.gen(function* () {
const ext = yield* McpExtensionService;
return yield* ext.getSource(path.namespace);
}).pipe(Effect.orDie),
)
.handle("updateSource", ({ path, payload }) =>
Effect.gen(function* () {
const ext = yield* McpExtensionService;
yield* ext.updateSource(path.namespace, {
endpoint: payload.endpoint,
headers: payload.headers,
queryParams: payload.queryParams,
auth: payload.auth as McpUpdateSourceInput["auth"],
});
return { updated: true };
}).pipe(Effect.orDie),
)
.handle("oauthCallback", ({ urlParams }) =>
Effect.gen(function* () {
const ext = yield* McpExtensionService;
Expand Down
250 changes: 232 additions & 18 deletions packages/plugins/mcp/src/react/EditMcpSource.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,259 @@
import { useState } from "react";
import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
import { mcpSourceAtom, updateMcpSource } from "./atoms";
import { useScope } from "@executor/react/api/scope-context";
import { Button } from "@executor/react/components/button";
import { Input } from "@executor/react/components/input";
import { Label } from "@executor/react/components/label";
import { Badge } from "@executor/react/components/badge";
import type { McpStoredSourceSchemaType } from "../sdk/stored-source";

// ---------------------------------------------------------------------------
// Edit MCP Source — config view for an existing MCP source
// Editable header entry
// ---------------------------------------------------------------------------

export default function EditMcpSource({
sourceId,
onSave,
}: {
readonly sourceId: string;
readonly onSave: () => void;
type HeaderEntry = {
readonly name: string;
readonly value: string;
};

// ---------------------------------------------------------------------------
// Remote edit form
// ---------------------------------------------------------------------------

function RemoteEditForm(props: {
sourceId: string;
initial: McpStoredSourceSchemaType & { config: { transport: "remote" } };
onSave: () => void;
}) {
const scopeId = useScope();
const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId));

const [endpoint, setEndpoint] = useState(props.initial.config.endpoint);
const [headerEntries, setHeaderEntries] = useState<HeaderEntry[]>(() =>
Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => ({
name,
value,
})),
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);

const updateHeader = (index: number, field: "name" | "value", val: string) => {
setHeaderEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
setDirty(true);
};

const removeHeader = (index: number) => {
setHeaderEntries((prev) => prev.filter((_, i) => i !== index));
setDirty(true);
};

const addHeader = () => {
setHeaderEntries((prev) => [...prev, { name: "", value: "" }]);
setDirty(true);
};

const handleSave = async () => {
setSaving(true);
setError(null);
try {
const headersObj: Record<string, string> = {};
for (const entry of headerEntries) {
const name = entry.name.trim();
if (name) headersObj[name] = entry.value;
}

await doUpdate({
path: { scopeId, namespace: props.sourceId },
payload: {
endpoint: endpoint.trim() || undefined,
headers: headersObj,
},
});
refreshSource();
setDirty(false);
props.onSave();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update source");
} finally {
setSaving(false);
}
};

return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
Manage settings for this MCP connection.
Update the endpoint and headers for this MCP connection.
</p>
</div>

<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<svg viewBox="0 0 16 16" className="size-4">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-card-foreground">{sourceId}</p>
<p className="truncate text-sm font-semibold text-card-foreground">{props.sourceId}</p>
</div>
<Badge variant="secondary" className="text-[10px]">
MCP
remote
</Badge>
</div>

{/* Endpoint */}
<section className="space-y-2">
<Label>Endpoint</Label>
<Input
value={endpoint}
onChange={(e) => {
setEndpoint((e.target as HTMLInputElement).value);
setDirty(true);
}}
placeholder="https://mcp.example.com"
className="font-mono text-sm"
/>
</section>

{/* Headers */}
<section className="space-y-2.5">
<Label>Headers</Label>
{headerEntries.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={entry.name}
onChange={(e) => updateHeader(i, "name", (e.target as HTMLInputElement).value)}
placeholder="Header name"
className="h-8 text-xs font-mono flex-1"
/>
<Input
value={entry.value}
onChange={(e) => updateHeader(i, "value", (e.target as HTMLInputElement).value)}
placeholder="Header value"
className="h-8 text-xs font-mono flex-1"
/>
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => removeHeader(i)}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full border-dashed"
onClick={addHeader}
>
+ Add header
</Button>
</section>

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

<div className="flex items-center justify-between border-t border-border pt-4">
<div />
<Button onClick={onSave}>Done</Button>
<Button variant="ghost" onClick={props.onSave}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!dirty || saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// Stdio read-only view
// ---------------------------------------------------------------------------

function StdioReadOnly(props: {
sourceId: string;
initial: McpStoredSourceSchemaType & { config: { transport: "stdio" } };
onSave: () => void;
}) {
const { command, args } = props.initial.config;
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
Stdio MCP sources cannot be edited in the UI. Modify the executor.jsonc config file directly.
</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-[10px]">
stdio
</Badge>
</div>

<div className="flex items-center justify-end border-t border-border pt-4">
<Button onClick={props.onSave}>Done</Button>
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------

export default function EditMcpSource({
sourceId,
onSave,
}: {
readonly sourceId: string;
readonly onSave: () => void;
}) {
const scopeId = useScope();
const sourceResult = useAtomValue(mcpSourceAtom(scopeId, sourceId));

if (!Result.isSuccess(sourceResult) || !sourceResult.value) {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
<p className="mt-1 text-[13px] text-muted-foreground">Loading configuration…</p>
</div>
</div>
);
}

const source = sourceResult.value;

if (source.config.transport === "stdio") {
return (
<StdioReadOnly
sourceId={sourceId}
initial={source as McpStoredSourceSchemaType & { config: { transport: "stdio" } }}
onSave={onSave}
/>
);
}

return (
<RemoteEditForm
sourceId={sourceId}
initial={source as McpStoredSourceSchemaType & { config: { transport: "remote" } }}
onSave={onSave}
/>
);
}
Loading