diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts index 250fdf36f..fbc2cbc78 100644 --- a/apps/cli/src/service.ts +++ b/apps/cli/src/service.ts @@ -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` @@ -668,7 +668,7 @@ export const generateWindowsHiddenLauncherVbs = (wrapperPath: string): string => const runSchtasks = (args: ReadonlyArray): Effect.Effect => 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, @@ -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 @@ -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." diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7f46b2279..7fcb53bea 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -3308,6 +3308,7 @@ export const createExecutor = connectionsRemove(ref), refresh: (ref) => connectionsRefresh(ref), resolveValue: (ref) => resolveConnectionValueByRef(ref), + resolveValues: (ref) => resolveConnectionValuesByRef(ref), }, providers: { list: () => providersList(), diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 337d2d031..1d4b55bfb 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -219,9 +219,13 @@ export interface PluginCtx { 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; + /** Resolve every named input for a connection through its provider. */ + readonly resolveValues: ( + ref: ConnectionRef, + ) => Effect.Effect, StorageFailure>; }; /** Registered credential backends — for discovery (browse a backend's items). */ diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d684..5548dfbfb 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,6 +4,7 @@ import { IntegrationSlug, InternalError, IntegrationAlreadyExistsError, + IntegrationNotFoundError, } from "@executor-js/sdk/shared"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; @@ -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 @@ -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), @@ -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 @@ -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( diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index 6d9048878..a63a22769 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -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"; @@ -29,6 +30,7 @@ const failingExtension: McpPluginExtension = { reconcileStdioConnections: () => unused, getServer: () => Effect.succeed(null), configureServer: () => unused, + refreshServerTools: () => unused, configureAuth: () => unused, }; @@ -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* () { @@ -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)( + 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"); + }), + ); }); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 6ca5c3188..56a42f126 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -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); }), ), ) diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index b6e391dbe..54ee7952d 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -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, @@ -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) @@ -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) // --------------------------------------------------------------------------- @@ -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([]); + const [stdioEnvText, setStdioEnvText] = useState(""); + const [stdioCwd, setStdioCwd] = useState(""); const stdioIdentity = useIntegrationIdentity({ fallbackName: isStdioPreset ? preset.name @@ -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, }); @@ -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 ---- @@ -507,14 +509,29 @@ export default function AddMcpSource(props: { /> + + setStdioCwd((e.target as HTMLInputElement).value)} + placeholder="/path/to/project" + className="font-mono text-sm" + data-ph-block + /> + + - setStdioEnvText((e.target as HTMLTextAreaElement).value)} + placeholder={"DEBUG=true\nAPI_BASE_URL=https://example.com"} + className="font-mono text-sm" + data-ph-block /> diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index bbb563e7e..777b75d86 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -13,10 +13,12 @@ import { type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; -import { Badge } from "@executor-js/react/components/badge"; -import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { Textarea } from "@executor-js/react/components/textarea"; +import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; -import { configureMcpAuth, mcpServerAtom } from "./atoms"; +import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; import type { McpAuthMethod, McpCanonicalAuthMethodInput, @@ -27,6 +29,16 @@ import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput, } from "./auth-method-config"; +import { + canonicalizeStdioConfig, + canonicalizeStdioDraft, + parseStdioArgs, + parseStdioEnv, + sameCanonicalStdioConfig, + stdioArgsToText, + stdioEnvParseErrorMessage, + stdioEnvToText, +} from "../sdk/stdio-config"; type McpServer = { readonly slug: IntegrationSlug; @@ -174,30 +186,194 @@ function RemoteEdit(props: { } // --------------------------------------------------------------------------- -// Stdio read-only view +// Stdio edit // --------------------------------------------------------------------------- -function StdioReadOnly(props: { +function StdioEdit(props: { server: McpServer & { config: Extract }; + onPendingChange?: EditSheetSectionProps["onPendingChange"]; }) { - const { command, args } = props.server.config; + const { server } = props; + const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); + + const [commandDraft, setCommandDraft] = useState(server.config.command); + const [argsDraft, setArgsDraft] = useState(() => stdioArgsToText(server.config.args)); + const [cwdDraft, setCwdDraft] = useState(server.config.cwd ?? ""); + const [envDraft, setEnvDraft] = useState(() => stdioEnvToText(server.config.env)); + const [error, setError] = useState(null); + + const lastResetSlug = useRef(null); + useEffect(() => { + if (lastResetSlug.current === server.slug) return; + lastResetSlug.current = server.slug; + setCommandDraft(server.config.command); + setArgsDraft(stdioArgsToText(server.config.args)); + setCwdDraft(server.config.cwd ?? ""); + setEnvDraft(stdioEnvToText(server.config.env)); + setError(null); + }, [ + server.config.args, + server.config.command, + server.config.cwd, + server.config.env, + server.slug, + ]); + + const storedArgsText = useMemo(() => stdioArgsToText(server.config.args), [server.config.args]); + const storedEnvText = useMemo(() => stdioEnvToText(server.config.env), [server.config.env]); + const stdioDraftChanged = useMemo( + () => + commandDraft !== server.config.command || + argsDraft !== storedArgsText || + cwdDraft !== (server.config.cwd ?? "") || + envDraft !== storedEnvText, + [ + argsDraft, + commandDraft, + cwdDraft, + envDraft, + server.config.command, + server.config.cwd, + storedArgsText, + storedEnvText, + ], + ); + + const parsedEnvForComparison = useMemo(() => parseStdioEnv(envDraft), [envDraft]); + const processConfigChanged = useMemo(() => { + if (!parsedEnvForComparison.ok) return false; + return !sameCanonicalStdioConfig( + canonicalizeStdioConfig(server.config), + canonicalizeStdioDraft({ + command: commandDraft, + args: parseStdioArgs(argsDraft), + env: parsedEnvForComparison.env, + cwd: cwdDraft, + }), + ); + }, [argsDraft, commandDraft, cwdDraft, parsedEnvForComparison, server.config]); + + const applyStaged = useCallback(async (): Promise => { + setError(null); + const command = commandDraft.trim(); + if (command.length === 0) { + setError("Command is required."); + return { ok: false }; + } + + const parsedEnv = parseStdioEnv(envDraft); + if (!parsedEnv.ok) { + setError(stdioEnvParseErrorMessage(parsedEnv.error)); + return { ok: false }; + } + + const args = parseStdioArgs(argsDraft); + const cwd = cwdDraft.trim(); + const nextConfig: Extract = { + transport: "stdio", + command, + ...(args.length > 0 ? { args } : {}), + ...(parsedEnv.env !== undefined ? { env: parsedEnv.env } : {}), + ...(cwd.length > 0 ? { cwd } : {}), + ...(server.config.authenticationTemplate !== undefined + ? { authenticationTemplate: server.config.authenticationTemplate } + : {}), + }; + + if ( + sameCanonicalStdioConfig( + canonicalizeStdioConfig(server.config), + canonicalizeStdioConfig(nextConfig), + ) + ) { + return { ok: true, summary: null }; + } + + const exit = await doConfigure({ + params: { slug: server.slug }, + payload: { config: nextConfig }, + reactivityKeys: integrationWriteKeys, + }); + if (Exit.isFailure(exit)) { + setError(errorMessageFromExit(exit, "Failed to update command settings")); + return { ok: false }; + } + if (exit.value.toolsRefreshFailed) { + return { + ok: true, + summary: + "Command settings updated, but tools could not be refreshed. Check secrets or retry.", + }; + } + return { ok: true, summary: "Command settings updated." }; + }, [argsDraft, commandDraft, cwdDraft, doConfigure, envDraft, server.config, server.slug]); + + const onPendingChangeRef = useRef(props.onPendingChange); + onPendingChangeRef.current = props.onPendingChange; + useEffect(() => { + onPendingChangeRef.current?.(stdioDraftChanged ? applyStaged : null); + return () => onPendingChangeRef.current?.(null); + }, [stdioDraftChanged, applyStaged]); + return ( -
+
-

Server command

-

- Stdio MCP sources cannot be edited. Remove and recreate the source with the updated - command. -

+

Command settings

+

Changes apply when you save.

-
-

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

- - stdio - + +
+ + + + + + +