From fe61bc920b24ecaf6a8ccf72b295dc80de29bb10 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:55:07 -0700 Subject: [PATCH] feat(mcp): add edit source configuration support Implement getSource/update for MCP with typed API routes, config-file store, stored-source schema, and edit UI for remote sources (endpoint + headers). Stdio sources show read-only config view. --- bun.lock | 1 + packages/plugins/mcp/package.json | 1 + packages/plugins/mcp/src/api/group.ts | 25 ++ packages/plugins/mcp/src/api/handlers.ts | 20 +- .../plugins/mcp/src/react/EditMcpSource.tsx | 250 ++++++++++++++++-- packages/plugins/mcp/src/react/atoms.ts | 12 + packages/plugins/mcp/src/sdk/binding-store.ts | 11 + .../plugins/mcp/src/sdk/config-file-store.ts | 70 +++++ packages/plugins/mcp/src/sdk/index.ts | 7 +- packages/plugins/mcp/src/sdk/plugin.ts | 65 ++++- packages/plugins/mcp/src/sdk/stored-source.ts | 18 ++ 11 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 packages/plugins/mcp/src/sdk/config-file-store.ts create mode 100644 packages/plugins/mcp/src/sdk/stored-source.ts diff --git a/bun.lock b/bun.lock index 645008d0c0..0ba7003473 100644 --- a/bun.lock +++ b/bun.lock @@ -494,6 +494,7 @@ "dependencies": { "@effect/platform": "catalog:", "@effect/platform-node": "catalog:", + "@executor/config": "workspace:*", "@executor/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 987fc0166d..3882b39289 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -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:" diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index aedcaa0768..6fdec1a561 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -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 }; @@ -10,6 +12,7 @@ export { HttpApiSchema }; // --------------------------------------------------------------------------- const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); +const namespaceParam = HttpApiSchema.param("namespace", Schema.String); // --------------------------------------------------------------------------- // Auth payload (only for remote) @@ -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, }); @@ -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), + ) {} diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 7e86cf5f25..40cde2077e 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -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"; // --------------------------------------------------------------------------- @@ -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; diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 1cd8a73080..16c50b9bef 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -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(() => + Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => ({ + name, + value, + })), + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 = {}; + 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 (

Edit MCP Source

- Manage settings for this MCP connection. + Update the endpoint and headers for this MCP connection.

-
- - - - -
-

{sourceId}

+

{props.sourceId}

- MCP + remote
+ {/* Endpoint */} +
+ + { + setEndpoint((e.target as HTMLInputElement).value); + setDirty(true); + }} + placeholder="https://mcp.example.com" + className="font-mono text-sm" + /> +
+ + {/* Headers */} +
+ + {headerEntries.map((entry, i) => ( +
+ updateHeader(i, "name", (e.target as HTMLInputElement).value)} + placeholder="Header name" + className="h-8 text-xs font-mono flex-1" + /> + updateHeader(i, "value", (e.target as HTMLInputElement).value)} + placeholder="Header value" + className="h-8 text-xs font-mono flex-1" + /> + +
+ ))} + +
+ + {error && ( +
+

{error}

+
+ )} +
-
- + +
); } + +// --------------------------------------------------------------------------- +// Stdio read-only view +// --------------------------------------------------------------------------- + +function StdioReadOnly(props: { + sourceId: string; + initial: McpStoredSourceSchemaType & { config: { transport: "stdio" } }; + onSave: () => void; +}) { + const { command, args } = props.initial.config; + return ( +
+
+

Edit MCP Source

+

+ Stdio MCP sources cannot be edited in the UI. Modify the executor.jsonc config file directly. +

+
+ +
+
+

{props.sourceId}

+

+ {command} {(args ?? []).join(" ")} +

+
+ + stdio + +
+ +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+

Edit MCP Source

+

Loading configuration…

+
+
+ ); + } + + const source = sourceResult.value; + + if (source.config.transport === "stdio") { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index 067ddd1ef5..bc508a30d1 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -1,5 +1,16 @@ +import type { ScopeId } from "@executor/sdk"; import { McpClient } from "./client"; +// --------------------------------------------------------------------------- +// Query atoms +// --------------------------------------------------------------------------- + +export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => + McpClient.query("mcp", "getSource", { + path: { scopeId, namespace }, + timeToLive: "15 seconds", + }); + // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- @@ -10,3 +21,4 @@ export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); export const startMcpOAuth = McpClient.mutation("mcp", "startOAuth"); export const completeMcpOAuth = McpClient.mutation("mcp", "completeOAuth"); +export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); diff --git a/packages/plugins/mcp/src/sdk/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index a57467ed55..c56b0979a8 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -73,6 +73,9 @@ export interface McpBindingStore { readonly putSource: (source: McpStoredSource) => Effect.Effect; readonly removeSource: (namespace: string) => Effect.Effect; readonly listSources: () => Effect.Effect; + readonly getSource: ( + namespace: string, + ) => Effect.Effect; readonly getSourceConfig: ( namespace: string, ) => Effect.Effect; @@ -146,6 +149,14 @@ const makeStore = ( return entries.map((e) => JSON.parse(e.value) as McpStoredSource); }), + getSource: (namespace) => + Effect.gen(function* () { + const raw = yield* sources.get(namespace); + if (!raw) return null; + // @effect-diagnostics-next-line preferSchemaOverJson:off + return JSON.parse(raw) as McpStoredSource; + }), + getSourceConfig: (namespace) => Effect.gen(function* () { const raw = yield* sources.get(namespace); diff --git a/packages/plugins/mcp/src/sdk/config-file-store.ts b/packages/plugins/mcp/src/sdk/config-file-store.ts new file mode 100644 index 0000000000..ef6259fc78 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/config-file-store.ts @@ -0,0 +1,70 @@ +/** + * Config-file wrapper for McpBindingStore. + * + * Decorates an underlying store so that `putSource` and `removeSource` also + * write to executor.jsonc. + */ + +import { Effect } from "effect"; +import { FileSystem } from "@effect/platform"; +import type { Layer } from "effect"; + +import { + addSourceToConfig, + removeSourceFromConfig, +} from "@executor/config"; +import type { SourceConfig as ConfigFileSourceConfig } from "@executor/config"; + +import type { McpBindingStore, McpStoredSource } from "./binding-store"; + +const toSourceConfig = (source: McpStoredSource): ConfigFileSourceConfig => { + if (source.config.transport === "stdio") { + const d = source.config; + return { + kind: "mcp", + transport: "stdio", + name: source.name, + command: d.command, + args: d.args ? [...d.args] : undefined, + env: d.env, + cwd: d.cwd, + namespace: source.namespace, + }; + } + + const d = source.config; + return { + kind: "mcp", + transport: "remote", + name: source.name, + endpoint: d.endpoint, + remoteTransport: d.remoteTransport, + queryParams: d.queryParams, + headers: d.headers, + namespace: source.namespace, + }; +}; + +export const withConfigFile = ( + inner: McpBindingStore, + configPath: string, + fsLayer: Layer.Layer, +): McpBindingStore => ({ + ...inner, + putSource: (source) => + Effect.gen(function* () { + yield* inner.putSource(source); + yield* addSourceToConfig(configPath, toSourceConfig(source)).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), + removeSource: (namespace) => + Effect.gen(function* () { + yield* inner.removeSource(namespace); + yield* removeSourceFromConfig(configPath, namespace).pipe( + Effect.provide(fsLayer), + Effect.catchAll(() => Effect.void), + ); + }), +}); diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index b203aed9a8..a14f03b82b 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -3,4 +3,9 @@ export { type McpPluginExtension, } from "./plugin"; -export { makeKvBindingStore } from "./binding-store"; +export { + makeKvBindingStore, + type McpBindingStore, + type McpStoredSource, +} from "./binding-store"; +export { withConfigFile } from "./config-file-store"; diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 13c02c236f..8d1bfcbf47 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -21,6 +21,7 @@ import { import { makeInMemoryBindingStore, type McpBindingStore, + type McpStoredSource, } from "./binding-store"; import { createMcpConnector, @@ -110,6 +111,13 @@ export interface McpProbeResult { readonly serverName: string | null; } +export interface McpUpdateSourceInput { + readonly endpoint?: string; + readonly headers?: Record; + readonly queryParams?: Record; + readonly auth?: McpConnectionAuth; +} + export interface McpPluginExtension { readonly probeEndpoint: ( endpoint: string, @@ -135,6 +143,17 @@ export interface McpPluginExtension { readonly completeOAuth: ( input: McpOAuthCompleteInput, ) => Effect.Effect; + + /** Fetch the full stored source by namespace (or null if missing) */ + readonly getSource: ( + namespace: string, + ) => Effect.Effect; + + /** Update config for an existing remote MCP source */ + readonly updateSource: ( + namespace: string, + input: McpUpdateSourceInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -296,9 +315,15 @@ export const mcpPlugin = (options?: { // Restore source metadata const savedSources = yield* bindingStore.listSources(); for (const s of savedSources) { + const isRemote = s.config.transport === "remote"; addedSources.set( s.namespace, - new Source({ id: s.namespace, name: s.name, kind: "mcp" }), + new Source({ + id: s.namespace, + name: s.name, + kind: "mcp", + canEdit: isRemote, + }), ); } @@ -594,6 +619,7 @@ export const mcpPlugin = (options?: { id: namespace, name: sourceName, kind: "mcp", + canEdit: config.transport === "remote", }), ); @@ -769,6 +795,41 @@ export const mcpPlugin = (options?: { }; }); + const updateSource = (namespace: string, input: McpUpdateSourceInput) => + Effect.gen(function* () { + const existingConfig = yield* bindingStore.getSourceConfig(namespace); + if (!existingConfig || existingConfig.transport !== "remote") return; + + const remote = existingConfig as Extract; + const updatedConfig: McpStoredSourceData = { + ...remote, + ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), + ...(input.headers !== undefined ? { headers: input.headers } : {}), + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}), + }; + + const sources = yield* bindingStore.listSources(); + const existingMeta = sources.find((s) => s.namespace === namespace); + + yield* bindingStore.putSource({ + namespace, + name: existingMeta?.name ?? namespace, + config: updatedConfig, + }); + + const toolIds = yield* bindingStore.listByNamespace(namespace); + for (const toolId of toolIds) { + const entry = yield* bindingStore.get(toolId); + if (entry) { + yield* bindingStore.put(toolId, namespace, entry.binding, updatedConfig); + } + } + }); + + const getSource = (namespace: string) => + bindingStore.getSource(namespace); + return { extension: { probeEndpoint, @@ -777,6 +838,8 @@ export const mcpPlugin = (options?: { refreshSource, startOAuth, completeOAuth, + getSource, + updateSource, }, close: () => diff --git a/packages/plugins/mcp/src/sdk/stored-source.ts b/packages/plugins/mcp/src/sdk/stored-source.ts new file mode 100644 index 0000000000..187e9cf1e3 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stored-source.ts @@ -0,0 +1,18 @@ +import { Schema } from "effect"; + +import { McpStoredSourceData } from "./types"; + +// --------------------------------------------------------------------------- +// Stored source — the shape persisted by the binding store and exposed +// via the getSource HTTP endpoint. +// --------------------------------------------------------------------------- + +export class McpStoredSourceSchema extends Schema.Class( + "McpStoredSource", +)({ + namespace: Schema.String, + name: Schema.String, + config: McpStoredSourceData, +}) {} + +export type McpStoredSourceSchemaType = typeof McpStoredSourceSchema.Type;