Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 96ac0dead8 feat(core): restore mcp elicitation via form service 2026-07-03 04:12:50 -05:00
2 changed files with 176 additions and 2 deletions
+32
View File
@@ -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<CallToolContent>
}
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<ElicitationResult, Error>
readonly complete: (input: {
readonly server: string
readonly elicitationID: ElicitRequestURLParams["elicitationId"]
}) => Effect.Effect<void>
}
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 }),
+144 -2
View File
@@ -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<ServerInfo[]>
readonly tools: () => Effect.Effect<Tool[]>
@@ -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<ServerName, ServerEntry>()
const urlElicitations = new Map<string, Form.ID>()
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<MCPClient.ElicitationResult["content"]>[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]