feat(core): port v2 command invocation (#34849)

This commit is contained in:
Aiden Cline 2026-07-02 11:22:04 -05:00 committed by GitHub
commit c176baee82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1445 additions and 179 deletions

View file

@ -9,8 +9,12 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
GetPromptResultSchema,
ListPromptsResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
PromptSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema,
@ -30,6 +34,9 @@ type Transport = StdioClientTransport | StreamableHTTPClientTransport
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
prompts: PromptSchema.array(),
})
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
@ -46,6 +53,25 @@ export interface ToolDefinition {
readonly inputSchema: unknown
}
export interface PromptDefinition {
readonly name: string
readonly description: string | undefined
readonly arguments: ReadonlyArray<{
readonly name: string
readonly description: string | undefined
readonly required: boolean | undefined
}> | undefined
}
export interface PromptMessage {
readonly role: string
readonly content: unknown
}
export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage>
}
export type CallToolContent =
| { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
@ -68,6 +94,13 @@ export interface Connection {
readonly instructions: string | undefined
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
readonly prompt: (input: {
readonly name: string
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult, Error>
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
readonly callTool: (input: {
readonly name: string
@ -78,6 +111,8 @@ export interface Connection {
readonly onLog: (callback: (message: LogMessage) => void) => void
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
readonly onToolsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
readonly onPromptsChanged: (callback: () => void) => void
}
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
@ -166,6 +201,48 @@ export const connect = Effect.fnUntraced(function* (
inputSchema: tool.inputSchema,
}))
}),
prompts: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return []
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: requestTimeout,
})
},
(result) => result.prompts,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
)
return prompts.map((prompt) => ({
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments?.map((argument) => ({
name: argument.name,
description: argument.description,
required: argument.required,
})),
}))
}),
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.map((result) => ({
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
})),
),
callTool: (input) =>
Effect.tryPromise({
try: (signal) =>
@ -207,6 +284,10 @@ export const connect = Effect.fnUntraced(function* (
if (!client.getServerCapabilities()?.tools?.listChanged) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
},
onPromptsChanged: (callback) => {
if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
},
} satisfies Connection
}

View file

@ -2,6 +2,7 @@ export * as MCP from "./index"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
@ -139,6 +140,7 @@ type ServerEntry = {
scope?: Scope.Closeable
client?: MCPClient.Connection
tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
integrationID?: Integration.ID
}
@ -309,6 +311,21 @@ export const layer = Layer.effect(
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
description: def.description,
arguments: def.arguments?.map(
(argument) =>
new PromptArgument({
name: argument.name,
description: argument.description,
required: argument.required,
}),
),
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
@ -316,6 +333,17 @@ export const layer = Layer.effect(
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.prompts().pipe(
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(events.publish(Command.Event.Updated, {})),
Effect.catch(() =>
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))),
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
connection.onClose(() => {
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
@ -323,8 +351,10 @@ export const layer = Layer.effect(
if (entry.client !== connection) return
entry.client = undefined
entry.tools = undefined
entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
})
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
@ -336,6 +366,9 @@ export const layer = Layer.effect(
),
)
})
connection.onPromptsChanged(() => {
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
})
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
@ -364,13 +397,14 @@ export const layer = Layer.effect(
// 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(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
Scope.provide(scope),
Effect.exit,
)
if (Exit.isSuccess(result)) {
entry.client = result.value.connection
entry.tools = result.value.defs.map((def) => toTool(name, def))
entry.tools = result.value.tools.map((def) => toTool(name, def))
entry.prompts = []
entry.status = { status: "connected" }
watch(name, entry, result.value.connection)
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
@ -379,6 +413,7 @@ export const layer = Layer.effect(
// stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
return
}
yield* Scope.close(scope, Exit.void)
@ -416,6 +451,8 @@ export const layer = Layer.effect(
entry.scope = undefined
entry.client = undefined
entry.tools = undefined
entry.prompts = undefined
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
}
yield* startServer(name, entry)
})
@ -489,12 +526,25 @@ export const layer = Layer.effect(
.toSorted((a, b) => a.server.localeCompare(b.server))
}),
prompts: Effect.fn("MCP.prompts")(function* () {
yield* whenAllReady
return []
return Array.from(runtime.values())
.flatMap((entry) => entry.prompts ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
prompt: Effect.fn("MCP.prompt")(function* (input) {
yield* gate(input.server)
return undefined
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result) return undefined
return new PromptResult({
server: target.name,
name: input.name,
messages: result.messages.map(
(message) => new PromptMessage({ role: message.role, content: message.content }),
),
})
}),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady