diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 8041ee2338..fcd0814fb8 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -9,7 +9,13 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, GetPromptResultSchema, + type ElicitRequestFormParams, + type ElicitRequestParams, + type ElicitRequestURLParams, + type ElicitResult, ListPromptsResultSchema, ListRootsRequestSchema, ListToolsResultSchema, @@ -82,6 +88,22 @@ export interface CallToolResult { readonly content: ReadonlyArray } +export type ElicitationFormParams = ElicitRequestFormParams +export type ElicitationParams = ElicitRequestParams +export type ElicitationResult = ElicitResult + +export interface ElicitationHandler { + readonly create: (input: { + readonly server: string + readonly params: ElicitationParams + readonly signal: AbortSignal + }) => Effect.Effect + readonly complete: (input: { + readonly server: string + readonly elicitationID: ElicitRequestURLParams["elicitationId"] + }) => Effect.Effect +} + export interface LogMessage { readonly level: LoggingMessageNotification["params"]["level"] readonly logger?: LoggingMessageNotification["params"]["logger"] @@ -123,6 +145,7 @@ export const connect = Effect.fnUntraced(function* ( // Only consumed by the remote transport; stdio servers have no auth concept. A provider with no // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth. authProvider?: OAuthClientProvider, + elicitation?: ElicitationHandler, ) { const transport: Transport = yield* Effect.gen(function* () { if (config.type === "local") { @@ -149,6 +172,7 @@ export const connect = Effect.fnUntraced(function* ( { name: "opencode", version: InstallationVersion }, { capabilities: { + ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), // https://github.com/anomalyco/opencode/issues/2308 roots: {}, }, @@ -157,6 +181,14 @@ export const connect = Effect.fnUntraced(function* ( client.setRequestHandler(ListRootsRequestSchema, () => Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), ) + if (elicitation) { + client.setRequestHandler(ElicitRequestSchema, (request, extra) => + Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })), + ) + client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) => + Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })), + ) + } const exit = yield* Effect.tryPromise({ try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }), diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 6992942674..2075c8dd19 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -10,9 +10,11 @@ import { Config } from "../config" import { ConfigMCP } from "../config/mcp" import { Credential } from "../credential" import { EventV2 } from "../event" +import { Form } from "../form" import { Integration } from "../integration" import { IntegrationConnection } from "../integration/connection" import { Location } from "../location" +import { waitForAbort } from "../process" import { MCPClient } from "./client" import { MCPOAuth } from "./oauth" @@ -145,6 +147,10 @@ type ServerEntry = { integrationID?: Integration.ID } +// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a +// persisted session row, so their forms are owned by this opaque sentinel session identifier. +const GLOBAL_ELICITATION_SESSION_ID = "global" + export interface Interface { readonly servers: () => Effect.Effect readonly tools: () => Effect.Effect @@ -175,6 +181,7 @@ export const layer = Layer.effect( const config = yield* Config.Service const location = yield* Location.Service const events = yield* EventV2.Service + const forms = yield* Form.Service const integration = yield* Integration.Service const credentials = yield* Credential.Service const root = yield* Scope.make() @@ -189,6 +196,7 @@ export const layer = Layer.effect( ) // Later config files win for duplicate server names; per-server timeout overrides globals. const runtime = new Map() + const urlElicitations = new Map() for (const entry of documents) { for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { runtime.set(ServerName.make(name), { @@ -308,6 +316,76 @@ export const layer = Layer.effect( }) }) + const elicitation = { + create: (input: { + readonly server: string + readonly params: MCPClient.ElicitationParams + readonly signal: AbortSignal + }) => + Effect.gen(function* () { + if (input.params.mode === "url") { + const formID = Form.ID.create() + const key = input.server + "\u0000" + input.params.elicitationId + urlElicitations.set(key, formID) + return yield* forms + .ask({ + id: formID, + sessionID: GLOBAL_ELICITATION_SESSION_ID, + title: `${input.server} is requesting input`, + metadata: { + kind: "mcp-elicitation", + server: input.server, + elicitationID: input.params.elicitationId, + message: input.params.message, + }, + mode: "url", + url: input.params.url, + }) + .pipe( + Effect.raceFirst(waitForAbort(input.signal)), + Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))), + Effect.map( + (state): MCPClient.ElicitationResult => ({ + action: state.status === "answered" ? "accept" : "cancel", + }), + ), + ) + } + const params = input.params + return yield* forms + .ask({ + sessionID: GLOBAL_ELICITATION_SESSION_ID, + title: `${input.server} is requesting input`, + metadata: { kind: "mcp-elicitation", server: input.server, message: params.message }, + mode: "form", + fields: Object.entries(params.requestedSchema.properties).map(([key, property]) => + toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true), + ), + }) + .pipe( + Effect.raceFirst(waitForAbort(input.signal)), + Effect.map((state): MCPClient.ElicitationResult => { + if (state.status !== "answered") return { action: "cancel" } + return { + action: "accept", + content: Object.fromEntries( + Object.entries(state.answer).map( + ([key, value]): [string, NonNullable[string]] => + typeof value === "object" ? [key, Array.from(value)] : [key, value], + ), + ), + } + }), + ) + }), + complete: (input: { readonly server: string; readonly elicitationID: string }) => + Effect.gen(function* () { + const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID) + if (!formID) return + yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore) + }), + } satisfies MCPClient.ElicitationHandler + const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) @@ -396,7 +474,7 @@ export const layer = Layer.effect( const authProvider = yield* connectProvider(entry) // List tools as part of connect so a failure here marks the server failed rather than // leaving it connected with a silently empty tool list and no path to recover. - const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe( + const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe( Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Scope.provide(scope), Effect.exit, @@ -561,5 +639,69 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node], + deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], }) + +// Schema `optional` strips undefined-valued properties on encode, so fields can assign +// optional properties directly instead of conditionally spreading them. +function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field { + // Some servers emit machine titles like "string with format email"; prefer description/key over those. + const machineTitle = /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i + const title = + property.title && !machineTitle.test(property.title.trim()) ? property.title : (property.description ?? key) + const base = { + key, + title, + description: property.description === title ? undefined : property.description, + required: required || undefined, + } + switch (property.type) { + case "boolean": + return { ...base, type: "boolean", default: property.default } + case "number": + case "integer": + return { + ...base, + type: property.type, + minimum: property.minimum, + maximum: property.maximum, + default: property.default, + } + case "array": + return { + ...base, + type: "multiselect", + options: + "anyOf" in property.items + ? property.items.anyOf.map((option) => ({ value: option.const, label: option.title })) + : property.items.enum.map((value) => ({ value, label: value })), + custom: false, + minItems: property.minItems, + maxItems: property.maxItems, + default: property.default, + } + case "string": { + const options = + "oneOf" in property + ? property.oneOf.map((option) => ({ value: option.const, label: option.title })) + : "enum" in property + ? property.enum.map((value, index) => ({ + value, + label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value, + })) + : undefined + return { + ...base, + type: "string", + format: "format" in property ? property.format : undefined, + minLength: "minLength" in property ? property.minLength : undefined, + maxLength: "maxLength" in property ? property.maxLength : undefined, + default: property.default, + options, + custom: options ? false : undefined, + } + } + } +} + +type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]