Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/opencode/src/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { isRecord } from "@/util/record"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

export const Sampling = Schema.Literals(["deny", "ask", "allow"])
.annotate({ identifier: "McpSamplingPolicy" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Sampling = Schema.Schema.Type<typeof Sampling>

const samplingField = Schema.optional(Sampling).annotate({
description:
"Policy for MCP client-side sampling (`sampling/createMessage`) from this server: deny, ask (default), or allow.",
})

export class Local extends Schema.Class<Local>("McpLocalConfig")({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
Expand All @@ -17,6 +27,7 @@ export class Local extends Schema.Class<Local>("McpLocalConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
sampling: samplingField,
}) {
static readonly zod = zod(this)
}
Expand Down Expand Up @@ -51,6 +62,7 @@ export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
sampling: samplingField,
}) {
static readonly zod = zod(this)
}
Expand Down
28 changes: 25 additions & 3 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { Log } from "../util"
import { NamedError } from "@mimo-ai/shared/util/error"
import z from "zod/v4"
import { Installation } from "../installation"

Check warning on line 17 in packages/opencode/src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Identifier 'Installation' is imported but never used.
import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@mimo-ai/shared/filesystem"
Expand All @@ -30,6 +30,8 @@
import { InstanceState } from "@/effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { McpSampling } from "./sampling"
import { SessionID } from "@/session/schema"

const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
Expand Down Expand Up @@ -77,8 +79,18 @@
// on again, so later turns abandon it instead of queueing behind it forever.
export const TURN_LIFECYCLE_STUCK_TIMEOUT = TURN_LIFECYCLE_NOTIFICATION_TIMEOUT

const turnLifecycleClientOptions = {
/**
* Capabilities MiMoCode declares in `initialize`. Exported so tests assert on the
* SAME object the client is constructed with rather than a copy that could drift.
*/
export const CLIENT_OPTIONS = {
capabilities: {
// Declared because we register a `sampling/createMessage` request handler
// below; the SDK's assertRequestHandlerCapability refuses the registration
// without it. Intentionally an empty object: `sampling.tools` and
// `sampling.context` are NOT implemented, and declaring them would invite
// servers to send `tools`/`includeContext` payloads we would have to reject.
sampling: {},
experimental: {
[TURN_LIFECYCLE_CAPABILITY]: { version: TURN_LIFECYCLE_VERSION },
},
Expand Down Expand Up @@ -314,6 +326,9 @@
execute: async (args: unknown, options) => {
const metadata =
context && supportsTurnLifecycle(client) ? { _meta: { [TURN_LIFECYCLE_CAPABILITY]: context } } : {}
// Recorded before the call so a `sampling/createMessage` arriving WHILE
// this call is in flight can address its approval prompt at this session.
if (context) McpSampling.setActiveSession(client, SessionID.make(context.sessionId))
return client.callTool(
{
name: mcpTool.name,
Expand Down Expand Up @@ -425,7 +440,7 @@
const auth = yield* McpAuth.Service
const bus = yield* Bus.Service
const createClient = () =>
new Client({ name: "mimocode", version: InstallationVersion }, turnLifecycleClientOptions)
new Client({ name: "mimocode", version: InstallationVersion }, CLIENT_OPTIONS)

type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport

Expand Down Expand Up @@ -657,6 +672,7 @@
s.defs[name] = listed
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
})
McpSampling.serve(name, client, bridge)
}

const state = yield* InstanceState.make<State>(
Expand Down Expand Up @@ -712,6 +728,7 @@
} catch {}
}
}
yield* McpSampling.cancelAll(client)
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}),
{ concurrency: "unbounded" },
Expand All @@ -728,7 +745,12 @@
const client = s.clients[name]
delete s.defs[name]
if (!client) return Effect.void
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
// Interrupt sampling still running for this client first: once the
// transport is gone its response can never be delivered, so the fiber
// would otherwise keep a model call alive with nowhere to send the result.
return McpSampling.cancelAll(client).pipe(
Effect.andThen(Effect.tryPromise(() => client.close()).pipe(Effect.ignore)),
)
}

const storeClient = Effect.fnUntraced(function* (
Expand Down
Loading
Loading