feat(core): MCP elicitation support (#35064)
This commit is contained in:
parent
e65477ab1d
commit
efcf2c3f5d
18 changed files with 1170 additions and 668 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// Temporary MCP elicitation escape hatch. Public Form routes remain session-shaped, but MCP
|
||||
// elicitations are still Location-scoped until the protocol path can attribute them to a Session.
|
||||
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,74 @@ export const layer = Layer.effect(
|
|||
})
|
||||
})
|
||||
|
||||
const elicitation = {
|
||||
create: (input: {
|
||||
readonly server: string
|
||||
readonly params: MCPClient.ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const toResult = (state: Form.State): MCPClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
if (input.params.mode === "url") return { action: "accept" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] => {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
return [key, value]
|
||||
return [key, Array.from(value)]
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
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(toResult),
|
||||
)
|
||||
}
|
||||
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(toResult))
|
||||
}),
|
||||
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 +472,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 +637,74 @@ 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],
|
||||
})
|
||||
|
||||
function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
|
||||
const title = elicitationFieldTitle(key, property)
|
||||
const description = property.description === title ? undefined : property.description
|
||||
const base = {
|
||||
key,
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(required ? { required: true } : {}),
|
||||
}
|
||||
switch (property.type) {
|
||||
case "boolean":
|
||||
return { ...base, type: "boolean", ...(property.default === undefined ? {} : { default: property.default }) }
|
||||
case "number":
|
||||
case "integer":
|
||||
return {
|
||||
...base,
|
||||
type: property.type,
|
||||
...(property.minimum === undefined ? {} : { minimum: property.minimum }),
|
||||
...(property.maximum === undefined ? {} : { maximum: property.maximum }),
|
||||
...(property.default === undefined ? {} : { 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,
|
||||
...(property.minItems === undefined ? {} : { minItems: property.minItems }),
|
||||
...(property.maxItems === undefined ? {} : { maxItems: property.maxItems }),
|
||||
...(property.default === undefined ? {} : { 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" in property) || property.format === undefined ? {} : { format: property.format }),
|
||||
...(!("minLength" in property) || property.minLength === undefined ? {} : { minLength: property.minLength }),
|
||||
...(!("maxLength" in property) || property.maxLength === undefined ? {} : { maxLength: property.maxLength }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
...(options === undefined ? {} : { options, custom: false }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function elicitationFieldTitle(key: string, property: ElicitationProperty) {
|
||||
if (property.title && !isSchemaTypeTitle(property.title)) return property.title
|
||||
if (property.description) return property.description
|
||||
return key
|
||||
}
|
||||
|
||||
function isSchemaTypeTitle(title: string) {
|
||||
return /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const layer = Layer.effectDiscard(
|
|||
form
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: input.questions.length === 1 ? input.questions[0]?.header : "Questions",
|
||||
...(input.questions.length === 1 ? {} : { title: "Questions" }),
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue