feat(core): port v2 command invocation (#34849)
This commit is contained in:
parent
016d296f7c
commit
c176baee82
26 changed files with 1445 additions and 179 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue