import { Effect, Layer, Option, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; import * as z from "zod/v4"; import { authToolFailure, AuthTemplateSlug, ConnectionName, definePlugin, IntegrationAlreadyExistsError, IntegrationSlug, mergeAuthTemplates, OAuthClientSlug, tool, ToolResult, type AuthMethodDescriptor, type Integration, type IntegrationConfig, type IntegrationRecord, type OAuthClientSummary, type Owner, type PluginCtx, type StaticToolSchema, type StorageFailure, type ToolAnnotations, type ToolDef, ToolName, } from "@executor-js/sdk"; import { TOKEN_VARIABLE, describeApiKeyAuthMethod, describeNoneAuthMethod, renderAuthPlacements, requiredPlacementVariables, } from "@executor-js/sdk/http-auth"; import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; import { discoverTools } from "./discover"; import { McpConnectionError, McpOAuthReauthorizationRequired, McpToolDiscoveryError, } from "./errors"; import { invokeMcpTool } from "./invoke"; import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; import { mcpPresets } from "./presets"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; import { McpAuthMethodInput, McpAuthShorthand, McpRemoteTransport, type McpAuthMethod, type McpToolAnnotations, expandMcpAuthMethodInputs, mcpAuthMethodFromShorthand, normalizeMcpAuthMethods, parseMcpIntegrationConfig, type McpIntegrationConfig as McpIntegrationConfigType, type McpStdioEnvMethod, type McpStdioIntegrationConfig, } from "./types"; const MCP_PLUGIN_ID = "mcp" as const; const legacyOAuthClientSlugCandidate = (value: string): string | null => { const slug = value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug.length > 0 ? slug : null; }; const legacyOAuthClientSlugCandidates = ( slug: string, integration: (Integration & { readonly config: IntegrationConfig }) | null, ): ReadonlySet => { const candidates = new Set(); const fromSlug = legacyOAuthClientSlugCandidate(slug); if (fromSlug) candidates.add(fromSlug); const fromDescription = integration == null ? null : legacyOAuthClientSlugCandidate(integration.description); if (fromDescription) candidates.add(fromDescription); return candidates; }; const oauthClientKey = (owner: Owner, slug: OAuthClientSlug): string => `${owner}:${String(slug)}`; const legacyMcpClientMatches = ( client: OAuthClientSummary, candidates: ReadonlySet, config: McpIntegrationConfigType | null, ): boolean => { if (!candidates.has(String(client.slug))) return false; if ( !config || config.transport !== "remote" || !config.authenticationTemplate.some((method: McpAuthMethod) => method.kind === "oauth2") ) { return false; } return client.grant === "authorization_code" && (client.resource ?? null) === config.endpoint; }; // --------------------------------------------------------------------------- // Tool annotations carry an `mcp` envelope alongside the executor's policy // hints. The executor persists `ToolDef.annotations` verbatim into the tool // row's JSON column, so the real MCP tool name + upstream annotations survive // to `invokeTool` / `resolveAnnotations` with no plugin-side store (resolveTools // has no ctx to write one anyway). The envelope is opaque to core. // --------------------------------------------------------------------------- interface McpToolStamp { readonly toolName: string; readonly upstream?: McpToolAnnotations; } type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp }; const McpStampSchema = Schema.Struct({ toolName: Schema.String, upstream: Schema.optional( Schema.Struct({ title: Schema.optional(Schema.String), readOnlyHint: Schema.optional(Schema.Boolean), destructiveHint: Schema.optional(Schema.Boolean), idempotentHint: Schema.optional(Schema.Boolean), openWorldHint: Schema.optional(Schema.Boolean), }), ), }); const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema }); const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp); const readStamp = (annotations: unknown): McpToolStamp | null => Option.match(decodeStamp(annotations), { onNone: () => null, onSome: (decoded) => decoded.mcp, }); // --------------------------------------------------------------------------- // Extension input shapes — `addServer` registers an MCP integration. A // connection (the credential) is then created against it via // `executor.connections.create` / `oauth.start`. // --------------------------------------------------------------------------- const McpRemoteServerInputSchema = Schema.Struct({ transport: Schema.optional(Schema.Literal("remote")), name: Schema.String, /** Agent-visible catalog description. Defaults to the display name. */ description: Schema.optional(Schema.String), endpoint: Schema.String, remoteTransport: Schema.optional(McpRemoteTransport), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), slug: Schema.optional(Schema.String), /** Declared auth methods a connection can be applied through. */ authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethodInput)), /** Single-method shorthand (legacy callers). Ignored when * `authenticationTemplate` is present. Defaults to none. */ auth: Schema.optional(McpAuthShorthand), }); const McpStdioServerInputSchema = Schema.Struct({ transport: Schema.Literal("stdio"), name: Schema.String, description: Schema.optional(Schema.String), 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 secret credentials, not here — so the UI * defines what env vars exist and the connect step provides the secrets. */ envVars: Schema.optional(Schema.Array(Schema.String)), /** Provide secret env values directly (programmatic / agent one-shot): the * add then auto-creates the connection holding them. The UI uses `envVars` * instead and leaves the values to the connect step. */ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), cwd: Schema.optional(Schema.String), slug: Schema.optional(Schema.String), }); const McpAddServerInputSchema = Schema.Union([ McpRemoteServerInputSchema, McpStdioServerInputSchema, ]); const McpAddServerOutputSchema = Schema.Struct({ slug: Schema.String, }); /** Input for the custom-method-create flow. `merge` (default) appends onto the * integration's existing `authenticationTemplate`; `replace` swaps the whole * declared set. Mirrors the OpenAPI/GraphQL `configureAuth` inputs. */ export const McpConfigureAuthInputSchema = Schema.Struct({ authenticationTemplate: Schema.Array(McpAuthMethodInput), mode: Schema.optional(Schema.Literals(["merge", "replace"])), }); export type McpConfigureAuthInput = typeof McpConfigureAuthInputSchema.Type; const McpProbeEndpointInputSchema = Schema.Struct({ endpoint: Schema.String, headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), }); const McpProbeEndpointOutputSchema = Schema.Struct({ connected: Schema.Boolean, requiresAuthentication: Schema.Boolean, requiresOAuth: Schema.Boolean, supportsDynamicRegistration: Schema.Boolean, name: Schema.String, slug: Schema.String, toolCount: Schema.NullOr(Schema.Number), serverName: Schema.NullOr(Schema.String), /** The server's `instructions` from initialize — prefill for the add form's * description. Only available when the probe connected unauthenticated. */ instructions: Schema.NullOr(Schema.String), }); // --------------------------------------------------------------------------- // Extension input/output shapes — `addServer` registers an MCP integration. A // connection (the credential) is then created against it via // `executor.connections.create` / `oauth.start`. Types are inferred from the // schemas above so the wire shape and the TS surface can't drift. // --------------------------------------------------------------------------- export type McpRemoteServerInput = typeof McpRemoteServerInputSchema.Type; export type McpStdioServerInput = typeof McpStdioServerInputSchema.Type; export type McpServerInput = typeof McpAddServerInputSchema.Type; export type McpProbeResult = typeof McpProbeEndpointOutputSchema.Type; export type McpProbeEndpointInput = typeof McpProbeEndpointInputSchema.Type; const McpGetServerInputSchema = Schema.Struct({ slug: Schema.String, }); const McpGetServerOutputSchema = Schema.Struct({ integration: Schema.NullOr(Schema.Unknown), }); const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< A, I >; const McpAddServerInputStandardSchema = schemaToStaticToolSchema(McpAddServerInputSchema); const McpAddServerOutputStandardSchema = schemaToStaticToolSchema(McpAddServerOutputSchema); const McpProbeEndpointInputStandardSchema = schemaToStaticToolSchema(McpProbeEndpointInputSchema); const McpProbeEndpointOutputStandardSchema = schemaToStaticToolSchema(McpProbeEndpointOutputSchema); const McpGetServerInputStandardSchema = schemaToStaticToolSchema(McpGetServerInputSchema); const McpGetServerOutputStandardSchema = schemaToStaticToolSchema(McpGetServerOutputSchema); const mcpToolFailure = (code: string, message: string, details?: unknown) => ToolResult.fail({ code, message, ...(details === undefined ? {} : { details }), }); const mcpInvocationAuthFailure = (input: { readonly status: 401 | 403; readonly integration: string; readonly connection: string; }) => authToolFailure({ code: "connection_rejected", message: input.status === 403 ? `MCP server rejected connection "${input.connection}" with HTTP 403. The credential may lack access or required scope; re-authenticate or update the connection before retrying this tool.` : `MCP server rejected connection "${input.connection}" with HTTP 401. Re-authenticate or update the connection before retrying this tool.`, source: { id: input.integration }, credential: { kind: "upstream", label: input.connection }, status: input.status, upstream: { status: input.status }, }); const mcpInvocationOAuthReauthFailure = (input: { readonly integration: string; readonly connection: string; }) => authToolFailure({ code: "oauth_reauth_required", message: `OAuth connection "${input.connection}" requires reauthorization before retrying this MCP tool.`, source: { id: input.integration }, credential: { kind: "oauth", label: input.connection }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const slugFrom = (slug: string): IntegrationSlug => IntegrationSlug.make(slug); const normalizeSlug = (input: McpServerInput): string => input.slug ?? deriveMcpNamespace({ name: input.name, endpoint: input.transport === "stdio" ? undefined : input.endpoint, command: input.transport === "stdio" ? input.command : undefined, }); /** Slug for a stdio server's secret-env auth method (one per integration). */ const STDIO_ENV_TEMPLATE = "env"; /** The secret env var NAMES a stdio add declares: the explicit `envVars` * declaration plus the keys of any one-shot `env` values, de-duplicated and * order-preserving. */ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { const names = new Set(input.envVars ?? []); for (const key of Object.keys(input.env ?? {})) names.add(key); return [...names]; }; const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { if (input.transport === "stdio") { // The config only DECLARES the secret env vars by NAME (a `stdio_env` // method); their values are credentials and live on the connection, never // in this blob. Names come from the explicit `envVars` declaration and/or // the keys of any one-shot `env` values. const vars = stdioEnvVarNames(input); return { transport: "stdio", command: input.command, args: input.args ? [...input.args] : undefined, cwd: input.cwd, authenticationTemplate: vars.length > 0 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] : [{ slug: "none", kind: "none" }], }; } return { transport: "remote", endpoint: input.endpoint, remoteTransport: input.remoteTransport ?? "auto", queryParams: input.queryParams, headers: input.headers, authenticationTemplate: input.authenticationTemplate ? normalizeMcpAuthMethods(input.authenticationTemplate) : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })], }; }; type JsonSchemaObject = Record & { readonly properties?: Record; }; const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { const defaultStructuredContentSchema = McpCallToolResultJsonSchema.properties?.structuredContent ?? {}; return { ...McpCallToolResultJsonSchema, properties: { ...McpCallToolResultJsonSchema.properties, structuredContent: structuredContentSchema === undefined ? defaultStructuredContentSchema : structuredContentSchema, isError: { const: false }, }, required: structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], }; }; /** Build the executor-facing ToolDef for one discovered MCP tool, stamping the * real MCP tool name + upstream annotations into the persisted annotations so * they survive to invokeTool with no plugin-side store. */ const toToolDef = (entry: McpToolManifestEntry): ToolDef => { const destructive = entry.annotations?.destructiveHint === true; const stamp: McpToolStamp = { toolName: entry.toolName, ...(entry.annotations ? { upstream: entry.annotations } : {}), }; const annotations: StampedAnnotations = { requiresApproval: destructive, ...(destructive ? { approvalDescription: entry.annotations?.title ?? entry.toolName } : {}), mcp: stamp, }; return { name: ToolName.make(entry.toolId), description: entry.description ?? `MCP tool: ${entry.toolName}`, inputSchema: entry.inputSchema, outputSchema: mcpCallToolResultOutputSchema(entry.outputSchema), annotations: annotations as ToolAnnotations, }; }; const McpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }); const McpToolCallEnvelope = Schema.Struct({ isError: Schema.optional(Schema.Boolean), content: Schema.optional(Schema.Array(Schema.Unknown)), }); const decodeMcpTextContent = Schema.decodeUnknownOption(McpTextContent); const decodeMcpToolCallEnvelope = Schema.decodeUnknownOption(McpToolCallEnvelope); const extractMcpErrorMessage = (content: unknown): string => { if (Array.isArray(content)) { for (const item of content) { const decoded = Option.getOrUndefined(decodeMcpTextContent(item)); if (decoded !== undefined && decoded.text.length > 0) return decoded.text; } } return "MCP tool returned an error"; }; /** Match `token` as a separator-bounded run inside a URL hostname or path, * used as a low-confidence detection hint when wire-shape detection fails. */ const urlMatchesToken = (url: URL, token: string): boolean => { const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i"); return re.test(url.hostname) || re.test(url.pathname); }; /** Translate a hard-stop probe outcome into a message a user can act on. * Auth-required shapes are routed to the auth editor upstream, so they never * reach here; the only outcomes are an unreachable endpoint or a non-MCP one. * Exported for tests. */ export const userFacingProbeMessage = ( shape: Extract, ): string => shape.kind === "unreachable" ? "Couldn't reach this URL. Check the address, your network, and that the server is running." : "This URL doesn't appear to host an MCP server. Double-check the address, including the path."; // --------------------------------------------------------------------------- // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the // transport sends it as a Bearer header. Refresh is core's responsibility // (the connection row carries the OAuth grant); this adapter never initiates // a new flow and fails loudly if the SDK tries to. // --------------------------------------------------------------------------- const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ get redirectUrl() { return "http://localhost/oauth/callback"; }, get clientMetadata() { return { redirect_uris: ["http://localhost/oauth/callback"], grant_types: ["authorization_code", "refresh_token"] as string[], response_types: ["code"] as string[], token_endpoint_auth_method: "none" as const, client_name: "Executor", }; }, clientInformation: () => undefined, saveClientInformation: () => undefined, tokens: () => ({ access_token: accessToken, token_type: "Bearer" }), saveTokens: () => undefined, redirectToAuthorization: async () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK OAuthClientProvider callback can only signal reauthorization by throwing throw new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required", }); }, saveCodeVerifier: () => undefined, codeVerifier: () => { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: MCP SDK OAuthClientProvider callback requires a thrown verifier failure throw new Error("No active PKCE verifier"); }, saveDiscoveryState: () => undefined, discoveryState: () => undefined, }); // --------------------------------------------------------------------------- // Connector input — render the integration config + the connection's resolved // value through the auth method the connection references (by template slug) // into a live `ConnectorInput`. // --------------------------------------------------------------------------- /** The auth method a connection binds: its `template` slug when it matches a * declared method. Otherwise fall back to the sole declared method — single- * method integrations historically accepted any template slug (rendering was * config-driven), so existing connections keep working. Ambiguity across * several methods renders no auth rather than guessing. */ const selectAuthMethod = ( config: McpIntegrationConfigType, templateSlug: string | null, ): McpAuthMethod | undefined => { const methods = config.authenticationTemplate ?? []; if (templateSlug !== null) { const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug); if (match) return match; } return methods.length === 1 ? methods[0] : undefined; }; const buildConnectorInput = ( config: McpIntegrationConfigType, values: Record, templateSlug: string | null, allowStdio: boolean, httpClientLayer?: Layer.Layer, ): Effect.Effect => { if (config.transport === "stdio") { if (!allowStdio) { return Effect.fail( new McpConnectionError({ transport: "stdio", message: "MCP stdio transport is disabled. Enable it by passing `dangerouslyAllowStdioMCP: true` to mcpPlugin() — only safe for trusted local contexts.", }), ); } // Secret env lives on the connection: render the bound `stdio_env` // method's vars from the connection's resolved values, layered over any // static (non-credential / legacy-inline) env in the config. A var that // resolved to nothing is skipped rather than injected as empty. const method = selectAuthMethod(config, templateSlug); const env: Record = { ...(config.env ?? {}) }; if (method?.kind === "stdio_env") { for (const variable of method.vars) { const value = values[variable]; if (value != null) env[variable] = value; } } return Effect.succeed({ transport: "stdio" as const, command: config.command, args: config.args, env: Object.keys(env).length > 0 ? env : undefined, cwd: config.cwd, } satisfies McpStdioIntegrationConfig); } // Credential placements render OVER the integration's static headers / // query params — a same-named static entry is overwritten. const headers: Record = { ...(config.headers ?? {}) }; const queryParams: Record = { ...(config.queryParams ?? {}) }; let authProvider: OAuthClientProvider | undefined; const auth = selectAuthMethod(config, templateSlug); if (auth?.kind === "apikey") { const rendered = renderAuthPlacements(auth.placements, values); Object.assign(headers, rendered.headers); Object.assign(queryParams, rendered.queryParams); } else if (auth?.kind === "oauth2") { const token = values[TOKEN_VARIABLE]; if (token != null) authProvider = makeOAuthProvider(token); } return Effect.succeed({ transport: "remote" as const, endpoint: config.endpoint, remoteTransport: config.remoteTransport ?? "auto", queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, headers: Object.keys(headers).length > 0 ? headers : undefined, authProvider, httpClientLayer, }); }; // --------------------------------------------------------------------------- // Declared auth methods — project the stored MCP config into the catalog's // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. Pure and // tolerant of a malformed or foreign config blob (returns `[]`). Exported for // tests. // // none → a no-auth method carrying no credential inputs // stdio → [] (no remote connection to configure) // apikey → carried placements (headers / query params) verbatim // oauth2 → an oauth method carrying the MCP endpoint to probe // (`discoveryUrl`). Endpoints/scopes are discovered // live at connect time, so they are NOT pre-resolved // here. We mark // `supportsDynamicRegistration: true` because MCP // OAuth servers are expected to support RFC 7591 DCR; // the connect flow probes to confirm and falls back. // --------------------------------------------------------------------------- /** A stdio server's secret env method, projected so the console can render one * credential input per env var (carrier `env`) and re-create the connection. */ const describeStdioEnvAuthMethod = (method: McpStdioEnvMethod): AuthMethodDescriptor => ({ id: method.slug, label: "Environment variables", kind: "apikey", template: method.slug, placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })), }); export const describeMcpAuthMethods = ( record: IntegrationRecord, ): readonly AuthMethodDescriptor[] => { const config = parseMcpIntegrationConfig(record.config); if (!config) return []; // Stdio servers declare a single `stdio_env` method (or `none`); remote // servers declare header/query/oauth methods. Both project from the same // optional `authenticationTemplate`. const methods = config.authenticationTemplate ?? []; return methods.map((method: McpAuthMethod): AuthMethodDescriptor => { if (method.kind === "stdio_env") return describeStdioEnvAuthMethod(method); if (method.kind === "apikey") return describeApiKeyAuthMethod(method); if (method.kind === "oauth2") { return { id: method.slug, label: "OAuth", kind: "oauth", template: method.slug, // Only remote configs carry an endpoint; stdio never reaches here with // oauth2. oauth: { discoveryUrl: config.transport === "remote" ? config.endpoint : undefined, supportsDynamicRegistration: true, }, }; } return describeNoneAuthMethod(method.slug); }); }; export const describeMcpIntegrationDisplay = ( record: IntegrationRecord, ): { readonly url?: string } => { const config = parseMcpIntegrationConfig(record.config); if (!config || config.transport === "stdio") return {}; return { url: config.endpoint }; }; // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- export interface McpPluginOptions { /** * Allow configuring stdio-transport MCP servers. Off by default. * * Stdio servers spawn a local subprocess that inherits the parent * `process.env`. Only enable for trusted single-user contexts. */ readonly dangerouslyAllowStdioMCP?: boolean; readonly httpClientLayer?: Layer.Layer; } export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const allowStdio = options?.dangerouslyAllowStdioMCP ?? false; const presetEntries = ( allowStdio ? mcpPresets : mcpPresets.filter((preset) => !("transport" in preset && preset.transport === "stdio")) ).map((preset) => ({ id: preset.id, name: preset.name, summary: preset.summary, ...("url" in preset && preset.url ? { url: preset.url } : {}), ...("endpoint" in preset && preset.endpoint ? { endpoint: preset.endpoint } : {}), ...(preset.icon ? { icon: preset.icon } : {}), ...(preset.featured ? { featured: preset.featured } : {}), transport: ("transport" in preset && preset.transport === "stdio" ? "stdio" : "remote") as | "stdio" | "remote", ...("command" in preset ? { command: preset.command } : {}), ...("args" in preset && preset.args ? { args: [...preset.args] } : {}), ...("env" in preset && preset.env ? { env: preset.env } : {}), })); return { id: MCP_PLUGIN_ID, packageName: "@executor-js/plugin-mcp", integrationPresets: presetEntries, // Surfaced to the client bundle via the Vite plugin. The MCP `./client` // factory reads `allowStdio` and gates the stdio tab + presets. clientConfig: { allowStdio }, storage: () => ({}), extension: (ctx: PluginCtx) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const probeEndpoint = (input: string | McpProbeEndpointInput) => Effect.gen(function* () { const endpoint = typeof input === "string" ? input : input.endpoint; const trimmed = endpoint.trim(); if (!trimmed) { return yield* new McpConnectionError({ transport: "remote", message: "Endpoint URL is required", }); } const name = yield* Effect.try({ try: () => new URL(trimmed).hostname, catch: () => "mcp", }).pipe(Effect.orElseSucceed(() => "mcp")); const slug = deriveMcpNamespace({ endpoint: trimmed }); const probeHeaders = typeof input === "string" ? undefined : input.headers; const probeQueryParams = typeof input === "string" ? undefined : input.queryParams; const connector = createMcpConnector({ transport: "remote", endpoint: trimmed, headers: probeHeaders, queryParams: probeQueryParams, httpClientLayer, }); const result = yield* discoverTools(connector).pipe( Effect.map((m) => ({ ok: true as const, manifest: m })), Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })), Effect.withSpan("mcp.plugin.discover_tools"), ); if (result.ok && result.manifest) { return { connected: true, requiresAuthentication: false, requiresOAuth: false, supportsDynamicRegistration: false, name: result.manifest.server?.name ?? name, slug, toolCount: result.manifest.tools.length, serverName: result.manifest.server?.name ?? null, instructions: result.manifest.server?.instructions ?? null, } satisfies McpProbeResult; } // Confirm the endpoint actually speaks MCP before classifying it as // OAuth-protected (an OAuth-protected non-MCP service would // otherwise be misclassified). const shape = yield* probeMcpEndpointShape(trimmed, { httpClientLayer, headers: probeHeaders, queryParams: probeQueryParams, }); // A `not-mcp`/auth-required shape only proves the endpoint returned // 401, but the add-flow recovery is the same as a spec-compliant MCP // auth challenge: declare auth and connect an account afterward. // Only an unreachable endpoint or a confirmed wrong-shape is a hard // stop. if (shape.kind === "unreachable") { return yield* new McpConnectionError({ transport: "remote", message: userFacingProbeMessage(shape), }); } if (shape.kind === "not-mcp") { if (shape.category === "wrong-shape") { return yield* new McpConnectionError({ transport: "remote", message: userFacingProbeMessage(shape), }); } return { connected: false, requiresAuthentication: true, requiresOAuth: false, supportsDynamicRegistration: false, name, slug, toolCount: null, serverName: null, instructions: null, } satisfies McpProbeResult; } const probeResult = yield* ctx.oauth.probe({ url: trimmed }).pipe( Effect.map((oauth) => ({ ok: true as const, oauth })), Effect.catch(() => Effect.succeed({ ok: false as const, oauth: null })), Effect.withSpan("mcp.plugin.probe_oauth"), ); if (probeResult.ok) { return { connected: false, requiresAuthentication: true, requiresOAuth: true, supportsDynamicRegistration: probeResult.oauth.registrationEndpoint != null, name, slug, toolCount: null, serverName: null, instructions: null, } satisfies McpProbeResult; } if (shape.requiresAuth) { return { connected: false, requiresAuthentication: true, requiresOAuth: false, supportsDynamicRegistration: false, name, slug, toolCount: null, serverName: null, instructions: null, } satisfies McpProbeResult; } return yield* new McpConnectionError({ transport: "remote", message: "This endpoint looks like MCP, but Executor couldn't discover tools from it. Check the URL and try again.", }); }).pipe( Effect.withSpan("mcp.plugin.probe_endpoint", { attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint }, }), ); const addServer = (input: McpServerInput) => Effect.gen(function* () { const slug = normalizeSlug(input); const config = toIntegrationConfig(input); // Block re-adding an existing slug. The core `integrations.register` // primitive upserts (so boot re-registration is idempotent), but an // explicit add must NOT silently clobber an existing integration's // tools, connections, and policies. To add more auth, update the // existing integration instead. const existing = yield* ctx.core.integrations.get(slugFrom(slug)); if (existing) { return yield* new IntegrationAlreadyExistsError({ slug: slugFrom(slug) }); } yield* ctx.core.integrations .register({ slug: slugFrom(slug), name: input.name, description: input.description?.trim() || input.name, config, canRemove: true, canRefresh: true, }) .pipe( Effect.withSpan("mcp.plugin.register_integration", { attributes: { "mcp.integration.slug": slug }, }), ); // Auto-create the stdio server's default connection so its tools are // discovered immediately (without it the integration lands with zero // connections and therefore zero tools — the fresh-install "no tools // detected" report). Two cases connect on add: // • one-shot `env` VALUES were supplied (agent path) → bind them as // the connection's secrets. // • the server needs NO secret env at all → a no-auth connection. // When the server only DECLARES env var names (the UI path), the // secrets are still missing, so we leave the connection to the connect // step where the user enters one masked value per declared var. if (input.transport === "stdio") { const hasValues = input.env != null && Object.keys(input.env).length > 0; const declaresSecrets = stdioEnvVarNames(input).length > 0; if (hasValues || !declaresSecrets) { yield* ctx.connections .create({ owner: "org", name: ConnectionName.make("default"), integration: slugFrom(slug), template: AuthTemplateSlug.make(hasValues ? STDIO_ENV_TEMPLATE : "none"), values: hasValues ? { ...input.env } : {}, }) .pipe( // These can't arise right after a successful register with // valid inputs, but the channel must stay within // McpExtensionFailure; surface them as a connection error // rather than swallow a real failure. Effect.catchTags({ IntegrationNotFoundError: (cause) => Effect.fail( new McpConnectionError({ transport: "stdio", message: cause.message }), ), CredentialProviderNotRegisteredError: (cause) => Effect.fail( new McpConnectionError({ transport: "stdio", message: cause.message }), ), InvalidConnectionInputError: (cause) => Effect.fail( new McpConnectionError({ transport: "stdio", message: cause.message }), ), }), Effect.withSpan("mcp.plugin.bootstrap_stdio_connection", { attributes: { "mcp.integration.slug": slug }, }), ); } } return { slug }; }).pipe( Effect.withSpan("mcp.plugin.add_server", { attributes: { "mcp.server.transport": input.transport ?? "remote", "mcp.server.name": input.name, }, }), ); // Heal stdio integrations that pre-date the auto-connect model: before it, // adding a stdio server registered only the integration, so it landed with // zero connections and therefore zero tools (the "no tools detected" // report). For each such integration with no connection, create the // default one — and move any legacy inline `env` (then stored plaintext in // the config blob) into the connection's secret store, rewriting the // config to the canonical shape that only declares the var NAMES. // // Idempotent and order-safe: once a connection exists the integration is // skipped; the secret is persisted (connection.create) BEFORE the config // is stripped, so a failure between the two leaves the env recoverable // (the connection has it, and the still-inline config env also works). A // single bad integration is logged and skipped, never failing the caller. const reconcileStdioConnections = () => Effect.gen(function* () { const integrations = yield* ctx.core.integrations.list(); for (const integration of integrations) { if (integration.kind !== MCP_PLUGIN_ID) continue; yield* Effect.gen(function* () { const record = yield* ctx.core.integrations.get(integration.slug); const config = record ? parseMcpIntegrationConfig(record.config) : null; if (!config || config.transport !== "stdio") return; // Only heal LEGACY pre-revamp stdio rows (no declared methods). // A new-shape row declares its auth method and owns its connection // lifecycle: a zero-connection one is INTENTIONAL — it declared // secret env vars (the UI "declare then connect" path) and is // awaiting its secrets. Auto-creating a no-auth connection here // would run a secret-needing server without its secret and clobber // that flow. if (config.authenticationTemplate !== undefined) return; const connections = yield* ctx.connections.list({ integration: integration.slug, }); if (connections.length > 0) return; // already connectable — nothing to heal. const inlineEnv = config.env ?? {}; const envVars = Object.keys(inlineEnv); const hasEnv = envVars.length > 0; yield* ctx.connections.create({ owner: "org", name: ConnectionName.make("default"), integration: integration.slug, template: AuthTemplateSlug.make(hasEnv ? STDIO_ENV_TEMPLATE : "none"), values: hasEnv ? { ...inlineEnv } : {}, }); // The secret is now on the connection: canonicalize this legacy // config (declare the var names as a stdio_env method, dropping the // inline plaintext values; or `none` for a no-secret server). const nextConfig: McpIntegrationConfigType = { transport: "stdio", command: config.command, args: config.args, cwd: config.cwd, authenticationTemplate: hasEnv ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] : [{ slug: "none", kind: "none" }], }; yield* ctx.core.integrations.update(integration.slug, { config: nextConfig }); }).pipe( Effect.catch((cause) => Effect.logWarning( `mcp: failed healing stdio connection for "${integration.slug}"`, cause, ), ), Effect.withSpan("mcp.plugin.reconcile_stdio_connection", { attributes: { "mcp.integration.slug": String(integration.slug) }, }), ); } }).pipe(Effect.withSpan("mcp.plugin.reconcile_stdio_connections")); const removeServer = (slug: string) => Effect.gen(function* () { const integration = slugFrom(slug); const record = yield* ctx.core.integrations.get(integration); const config = record ? parseMcpIntegrationConfig(record.config) : null; const legacyCandidates = legacyOAuthClientSlugCandidates(slug, record); const connections = yield* ctx.connections.list({ integration }); const allConnections = yield* ctx.connections.list(); const oauthClientSummaries = yield* ctx.oauth.listClients(); const usedElsewhere = new Set( allConnections .filter((connection) => String(connection.integration) !== String(integration)) .flatMap((connection) => connection.oauthClient == null ? [] : [ oauthClientKey( connection.oauthClientOwner ?? connection.owner, connection.oauthClient, ), ], ), ); const oauthClientsByKey = new Map( oauthClientSummaries.map((client) => [ oauthClientKey(client.owner, client.slug), client, ]), ); const clientsToRemove = new Map< string, { readonly owner: Owner; readonly slug: OAuthClientSlug } >(); for (const connection of connections) { if (connection.oauthClient == null) continue; const owner = connection.oauthClientOwner ?? connection.owner; const key = oauthClientKey(owner, connection.oauthClient); const client = oauthClientsByKey.get(key); if (client?.origin.kind !== "dynamic_client_registration") continue; clientsToRemove.set(key, { owner, slug: connection.oauthClient, }); } for (const client of oauthClientSummaries) { const key = oauthClientKey(client.owner, client.slug); if (usedElsewhere.has(key)) continue; if ( client.origin.kind === "dynamic_client_registration" && (client.origin.integration == null || String(client.origin.integration) === slug) ) { clientsToRemove.set(key, { owner: client.owner, slug: client.slug }); continue; } if (legacyMcpClientMatches(client, legacyCandidates, config)) { clientsToRemove.set(key, { owner: client.owner, slug: client.slug }); } } yield* ctx.core.integrations .remove(integration) .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); yield* Effect.forEach( clientsToRemove.values(), (client) => ctx.oauth.removeClient(client.owner, client.slug), { discard: true }, ); }).pipe( Effect.withSpan("mcp.plugin.remove_server", { attributes: { "mcp.integration.slug": slug }, }), ); const getServer = (slug: string) => ctx.core.integrations.get(slugFrom(slug)).pipe( Effect.withSpan("mcp.plugin.get_server", { attributes: { "mcp.integration.slug": slug }, }), ); const configureServer = (slug: string, config: McpIntegrationConfigType) => ctx.core.integrations.update(slugFrom(slug), { config }).pipe( Effect.withSpan("mcp.plugin.configure_server", { attributes: { "mcp.integration.slug": slug }, }), ); /** Merge-append auth methods onto the integration's existing * `authenticationTemplate` (custom-method-create flow), mirroring the * OpenAPI/GraphQL `configureAuth`. Returns the merged array. A no-op * (returns `[]`) for an unknown slug, a stdio server, or an * undecodable config. */ const configureAuth = (slug: string, input: McpConfigureAuthInput) => Effect.gen(function* () { const record = yield* ctx.core.integrations.get(slugFrom(slug)); const current = record ? parseMcpIntegrationConfig(record.config) : null; if (!current || current.transport === "stdio") { return [] as readonly McpAuthMethod[]; } // Replace mode declares the full set — backfill kind-based slugs. // Merge mode appends: `mergeAuthTemplates` replaces on slug match and // assigns fresh `custom_` slugs to slug-less entries, so a custom // method never silently displaces a declared one. const merged = input.mode === "replace" ? normalizeMcpAuthMethods(input.authenticationTemplate) : mergeAuthTemplates( current.authenticationTemplate, expandMcpAuthMethodInputs( input.authenticationTemplate, ) as readonly McpAuthMethod[], ); yield* ctx.core.integrations.update(slugFrom(slug), { config: { ...current, authenticationTemplate: merged }, }); return merged; }).pipe( Effect.withSpan("mcp.plugin.configure_auth", { attributes: { "mcp.integration.slug": slug }, }), ); return { probeEndpoint, addServer, removeServer, reconcileStdioConnections, getServer, configureServer, configureAuth, }; }, // ----------------------------------------------------------------------- // Per-connection tool production. Dial the server using the connection's // resolved value (rendered through the integration's auth template) and // list its tools. The real MCP tool name + upstream annotations are // stamped into each ToolDef's annotations so invokeTool can recover them. // Discovery failures (auth not ready, server down) yield an empty tool set // rather than failing — the connection still lands and can be refreshed. // ----------------------------------------------------------------------- resolveTools: ({ config, connection, template, getValues, httpClientLayer }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(config); if (!parsed) return { tools: [] as readonly ToolDef[] }; // Discovery tolerates unresolved credentials (an open server lists // tools unauthenticated; a bad value just yields zero tools). const values = yield* getValues().pipe( Effect.orElseSucceed(() => ({}) as Record), ); const built = yield* buildConnectorInput( parsed, values, template === null ? null : String(template), allowStdio, httpClientLayer, ).pipe( Effect.map((ci) => createMcpConnector(ci)), Effect.result, ); const manifest = Result.isSuccess(built) ? yield* discoverTools(built.success).pipe( Effect.map((m) => ({ ok: true as const, manifest: m })), Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })), Effect.withSpan("mcp.plugin.discover_tools", { attributes: { "mcp.connection.name": String(connection.name) }, }), ) : { ok: false as const, manifest: null }; const entries = manifest.ok && manifest.manifest ? manifest.manifest.tools : []; return { tools: entries.map(toToolDef) }; }).pipe( Effect.withSpan("mcp.plugin.resolve_tools", { attributes: { "mcp.connection.name": String(connection.name) }, }), ) as Effect.Effect<{ readonly tools: readonly ToolDef[] }, StorageFailure>, invokeTool: ({ ctx, toolRow, credential, args, elicit }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(credential.config); if (!parsed) { return yield* new McpConnectionError({ transport: "auto", message: `MCP integration "${toolRow.integration}" has no usable config`, }); } const stamp = readStamp(toolRow.annotations); if (!stamp) { return yield* new McpToolDiscoveryError({ stage: "list_tools", message: `Tool "${toolRow.name}" is missing its MCP binding — refresh the connection`, }); } const transport: string = parsed.transport === "stdio" ? "stdio" : (parsed.remoteTransport ?? "auto"); // An apikey method with unresolved inputs fails the invocation // explicitly instead of dialing unauthenticated. if (parsed.transport === "remote") { const method = selectAuthMethod(parsed, String(credential.template)); if (method?.kind === "apikey") { const missing = requiredPlacementVariables(method.placements).filter( (variable) => credential.values[variable] == null, ); if (missing.length > 0) { return authToolFailure({ code: "connection_value_missing", message: `Connection has no resolvable credential value for input(s): ${missing.join(", ")}. Re-create the connection with the required value(s).`, source: { id: String(credential.integration) }, credential: { kind: "upstream", label: String(credential.connection) }, }); } } } const connector: McpConnector = yield* buildConnectorInput( parsed, credential.values, String(credential.template), allowStdio, options?.httpClientLayer ?? ctx.httpClientLayer, ).pipe(Effect.map((ci) => createMcpConnector(ci))); const raw = yield* invokeMcpTool({ toolId: String(toolRow.name), toolName: stamp.toolName, args, transport, connector, elicit, }); const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw)); if (envelope?.isError === true) { return ToolResult.fail({ code: "mcp_tool_error", message: extractMcpErrorMessage(envelope.content), details: { content: envelope.content }, }); } return ToolResult.ok(raw); }).pipe( Effect.catchTag("McpOAuthReauthorizationRequired", () => Effect.succeed( mcpInvocationOAuthReauthFailure({ integration: String(credential.integration), connection: String(credential.connection), }), ), ), Effect.catchTag("McpConnectionError", (error) => { return Effect.succeed( authToolFailure({ code: "connection_rejected", message: error.message, source: { id: String(credential.integration) }, credential: { kind: "upstream", label: String(credential.connection) }, }), ); }), Effect.catchTag("McpInvocationError", (error) => { if (error.status === 401 || error.status === 403) { return Effect.succeed( mcpInvocationAuthFailure({ status: error.status, integration: String(credential.integration), connection: String(credential.connection), }), ); } return Effect.fail(error); }), Effect.withSpan("mcp.plugin.invoke_tool", { attributes: { "mcp.tool.name": String(toolRow.name), "mcp.integration.slug": String(toolRow.integration), }, }), ), detect: ({ ctx, url }: { readonly ctx: PluginCtx; readonly url: string }) => Effect.gen(function* () { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const trimmed = url.trim(); if (!trimmed) return null; const parsed = yield* Effect.try({ try: () => new URL(trimmed), catch: (cause) => cause, }).pipe(Effect.option); if (Option.isNone(parsed)) return null; const name = parsed.value.hostname || "mcp"; const slug = deriveMcpNamespace({ endpoint: trimmed }); const connector = createMcpConnector({ transport: "remote", endpoint: trimmed, httpClientLayer, }); const connected = yield* discoverTools(connector).pipe( Effect.map(() => true), Effect.catch(() => Effect.succeed(false)), Effect.withSpan("mcp.plugin.discover_tools"), ); if (connected) { return { kind: MCP_PLUGIN_ID, confidence: "high" as const, endpoint: trimmed, name, slug, }; } const shape = yield* probeMcpEndpointShape(trimmed, { httpClientLayer }); if (shape.kind === "mcp") { return { kind: MCP_PLUGIN_ID, confidence: "high" as const, endpoint: trimmed, name, slug, }; } // Low-confidence URL-token fallback when wire-shape detection can't // confirm MCP but the URL itself is a strong hint. if (urlMatchesToken(parsed.value, "mcp")) { return { kind: MCP_PLUGIN_ID, confidence: "low" as const, endpoint: trimmed, name, slug, }; } return null; }).pipe( Effect.catch(() => Effect.succeed(null)), Effect.withSpan("mcp.plugin.detect", { attributes: { "mcp.endpoint": url }, }), ), // Honour upstream destructiveHint from MCP ToolAnnotations using the stamp // persisted in each tool row's annotations. resolveAnnotations: ({ toolRows }) => Effect.sync(() => { const out: Record = {}; for (const row of toolRows) { const stamp = readStamp(row.annotations); const ann = stamp?.upstream; if (ann?.destructiveHint === true) { out[String(row.name)] = { requiresApproval: true, approvalDescription: ann.title ?? stamp?.toolName ?? String(row.name), }; } else { out[String(row.name)] = { requiresApproval: false }; } } return out; }), describeAuthMethods: describeMcpAuthMethods, describeIntegrationDisplay: describeMcpIntegrationDisplay, integrationConfigure: { type: "mcp", configure: ({ ctx, integration, config }) => Effect.gen(function* () { const next = parseMcpIntegrationConfig(config); if (!next) return; yield* ctx.core.integrations.update(integration, { config: next }); }), }, staticSources: (self) => [ { id: "mcp", kind: "executor", name: "MCP", tools: [ tool({ name: "probeEndpoint", description: "Probe a remote MCP endpoint before adding it. If the result requires OAuth, run the core OAuth handoff (`oauth.probe`, `oauth.start`) to mint a connection; otherwise create a connection with `connections.create` carrying the API key or header value.", inputSchema: McpProbeEndpointInputStandardSchema, outputSchema: McpProbeEndpointOutputStandardSchema, execute: (input) => self.probeEndpoint(input as McpProbeEndpointInput).pipe( Effect.map(ToolResult.ok), Effect.catchTag("McpConnectionError", ({ message, transport }) => Effect.succeed(mcpToolFailure("mcp_connection_failed", message, { transport })), ), ), }), tool({ name: "getServer", description: "Inspect a registered MCP integration, including transport, endpoint/command, and auth template. Use this before creating a connection (`connections.create` / `oauth.start`).", inputSchema: McpGetServerInputStandardSchema, outputSchema: McpGetServerOutputStandardSchema, execute: (input) => { const args = input as typeof McpGetServerInputSchema.Type; return Effect.map(self.getServer(args.slug), (integration) => ToolResult.ok({ integration }), ); }, }), tool({ name: "addServer", description: "Register an MCP server in the catalog as an integration. Returns its `slug`. Then create a connection against it: for header/API-key auth call `connections.create` with the value; for OAuth-protected servers run `oauth.probe` + `oauth.start`. Tools are produced per-connection at connection create / refresh.", annotations: { requiresApproval: true, approvalDescription: "Add an MCP server", }, inputSchema: McpAddServerInputStandardSchema, outputSchema: McpAddServerOutputStandardSchema, execute: (rawInput) => { const input = rawInput as typeof McpAddServerInputSchema.Type; return self.addServer(input as McpServerInput).pipe( Effect.map(ToolResult.ok), Effect.catchTag( "IntegrationAlreadyExistsError", ({ slug }: IntegrationAlreadyExistsError) => Effect.succeed( mcpToolFailure( "integration_already_exists", `Integration ${slug} already exists; update it instead of re-adding.`, ), ), ), ); }, }), ], }, ], }; }); // --------------------------------------------------------------------------- // McpPluginExtension — shape of `executor.mcp` for consumers (api/, react/). // --------------------------------------------------------------------------- export type McpExtensionFailure = McpConnectionError | McpToolDiscoveryError | StorageFailure; export interface McpPluginExtension { readonly probeEndpoint: ( input: string | McpProbeEndpointInput, ) => Effect.Effect; readonly addServer: ( input: McpServerInput, ) => Effect.Effect< { readonly slug: string }, McpExtensionFailure | IntegrationAlreadyExistsError >; readonly removeServer: (slug: string) => Effect.Effect; /** Ensure every stdio integration has its default connection (migrating any * legacy inline env into the secret store). Idempotent; safe to run at boot. */ readonly reconcileStdioConnections: () => Effect.Effect; readonly getServer: ( slug: string, ) => Effect.Effect< (Integration & { readonly config: IntegrationConfig }) | null, McpExtensionFailure >; readonly configureServer: ( slug: string, config: McpIntegrationConfigType, ) => Effect.Effect; readonly configureAuth: ( slug: string, input: McpConfigureAuthInput, ) => Effect.Effect; }