diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 1a28bbc51..67d95ad2c 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -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 + +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("McpLocalConfig")({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ @@ -17,6 +27,7 @@ export class Local extends Schema.Class("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) } @@ -51,6 +62,7 @@ export class Remote extends Schema.Class("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) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 0f0d0a4e3..c2e95b148 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -30,6 +30,8 @@ import { EffectBridge } from "@/effect" 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 @@ -77,8 +79,18 @@ export const TURN_LIFECYCLE_NOTIFICATION_TIMEOUT = 1_000 // 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 }, }, @@ -314,6 +326,9 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number 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, @@ -425,7 +440,7 @@ export const layer = Layer.effect( 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 @@ -657,6 +672,7 @@ export const layer = Layer.effect( 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( @@ -712,6 +728,7 @@ export const layer = Layer.effect( } catch {} } } + yield* McpSampling.cancelAll(client) yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore) }), { concurrency: "unbounded" }, @@ -728,7 +745,12 @@ export const layer = Layer.effect( 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* ( diff --git a/packages/opencode/src/mcp/sampling.ts b/packages/opencode/src/mcp/sampling.ts new file mode 100644 index 000000000..f497eaf52 --- /dev/null +++ b/packages/opencode/src/mcp/sampling.ts @@ -0,0 +1,1029 @@ +import { Effect, Cause, Exit, Fiber } from "effect" +import { streamText, type ModelMessage } from "ai" +import { CreateMessageRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js" +import { Config } from "@/config" +import { Permission } from "@/permission" +import { Provider, ProviderTransform, ModelCapability } from "@/provider" +import { InstallationVersion } from "@/installation/version" +import { Log } from "@/util" +import type { SessionID } from "@/session/schema" + +const log = Log.create({ service: "mcp.sampling" }) + +/** + * MCP client-side sampling (`sampling/createMessage`). + * + * An MCP server asks US to run a model call on its behalf, so the server never + * needs its own API key. Everything about which model runs, whether the user + * agreed, and what the payload may contain is decided here — the server only + * expresses preferences. + * + * Spec: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling + */ + +/** + * SAMPLING INVENTS NO TIMEOUT NUMBERS. There is no total bound on the request, no + * bound on the model call, and no bound on the human approval wait. The one + * remaining silence bound is inherited from the provider layer; the one remaining + * interval is a deliberate choice about keepalive cadence. Everything below is why, + * because deleting guards obliges naming what stops being caught. + * + * THREE BOUNDS WERE REMOVED, ALL FOR THE SAME REASON: each was a number with no + * precedent in this repo, tighter than anything comparable, presented as policy. + * Two rounds of documentation had made them honest about lacking a derivation + * without ever asking the prior question — does a bound belong here at all? For + * each one the repo already had an answer, and in each case the answer was no. + * + * 1. THE TOTAL BOUNDS (a 120 s ceiling on the model call, and in `serve` an + * absolute ceiling equal to the sum of the phase bounds). `src/session/llm.ts` + * settles this for a real conversation: grep it for `Effect.timeout`, + * `AbortSignal.timeout` or `Schedule.upTo` and nothing comes back, and its retry + * schedule says so in words — "Intentionally NOT capped via Schedule.upTo() — + * retry persistence under brief upstream outages is the design goal. Bounding + * per-attempt latency via chunkTimeout is the primary lever for hang-time + * control" — with a worst case it states as ~97 minutes. For a streaming model + * call this repo's position is that ELAPSED TOTAL IS NOT A HEALTH SIGNAL, + * SILENCE IS. Sampling calls the same provider through the same SDK; a 2-minute + * total budget made it ~48x more impatient than the main path for no stated + * reason. + * + * 2. THE APPROVAL BOUND (30 s). `src/permission/index.ts` settles this too, and the + * other way round from how it was assumed: THE ORDINARY INTERACTIVE ASK HAS NO + * TIMEOUT AT ALL. It awaits the Deferred raced against the caller's abort signal, + * so a human takes as long as they take. Only two special cases are bounded — a + * FORWARDED ask (`FORWARD_DENY_TIMEOUT_MS`, :24) and a forced-ask under skip-all + * (`skipAllForcedAskTimeoutMs`, :29, env-overridable). Sampling's ask is neither: + * it passes no `forward`, and `mcp_sampling` is not in `FORCED_ASK` (:195, which + * holds only `bash_delete`). So a TUI chat prompt waits indefinitely while + * sampling used to give up at 30 s on the same kind of prompt. + * + * WHAT NO LONGER GETS CAUGHT, stated rather than glossed. The stall detector covers + * a provider that goes quiet, on any request. Three things it does not cover: + * a. A PATHOLOGICAL-BUT-ALIVE STREAM — trickling just often enough never to look + * stalled and never finishing. `llm.ts` accepts exactly this risk: `chunkTimeout` + * is also a bound on the GAP, so a single trickling attempt is unbounded there + * too. + * b. THE PRE-MODEL STRETCH — content conversion, provider listing, model + * selection, adapter initialisation — which only the `serve` ceiling covered. + * `llm.ts` calls the same `provider.getLanguage` (llm.ts:417) unbounded. + * c. AN APPROVAL NOBODY ANSWERS. Four things still release it: the operator + * replying; the peer cancelling (`extra.signal` is composed in, and a rejection + * settles promptly since the `raceFirst` fix); the peer's OWN request timeout, + * which is what produces that cancellation; and `cancelAll` when the client + * closes or the Instance tears down (`mcp/index.ts:731`, `:751`). The one + * residual is narrow and is named here rather than bounded: for the FIRST + * server-initiated request of a connection the SDK drops the cancellation (see + * the id-0 describe block in `test/mcp/sampling-e2e.test.ts`), so if that peer + * also holds the connection open and the operator never answers, the prompt + * pends until the client closes. That is a pending prompt a human can see and + * answer, which is the state this repo already accepts for every other ask — + * not a silent hang — and the peer is protected by its own timeout regardless. + * The same id-0 gap is why the stall detector is NOT gated on the peer having + * asked for progress: for that one request it is the only reaper. + */ + +/** + * How often a liveness notification goes out while the model call is in flight. + * + * ITS JOB IS TO KEEP THE CONNECTION AND THE PEER'S TIMER FROM GOING IDLE, and that + * is now the whole of it. This value used to be justified against the silence bound + * as well — "3x below it, so a peer sees at least two beats before a stall is + * declared" — and that ratio is void: the silence bound is now the provider's + * `chunkTimeout` (8 minutes by default), against which 15 s is ~32x rather than 3x. + * The surviving reason is the one that never depended on the stall bound: a beat + * every 15 s sits well inside the MCP SDK's 60 s DEFAULT_REQUEST_TIMEOUT_MSEC, so + * several land within one peer timeout window instead of one landing near its edge, + * and an intermediary that drops idle connections sees continuous traffic. + * + * It is the ONE timeout-shaped number sampling still chooses for itself. + */ +export const DEFAULT_LIVENESS_INTERVAL = 15_000 + +/** + * How long the model may produce NOTHING before we call it stalled — resolved from + * the SAME per-provider `chunkTimeout` the main conversation path uses, not from a + * number this module invented. + * + * IT USED TO BE OURS: `DEFAULT_SAMPLING_STALL_TIMEOUT = 45_000`, justified only as + * 3x the liveness interval. The repo already had this exact concept — "no output + * for this long means the stream is dead" — as `chunkTimeout`, wired in + * `provider.ts:wrapSSE`, configurable per provider in `mimocode.json`, default + * `DEFAULT_CHUNK_TIMEOUT` = 8 minutes. Carrying a second, tighter, differently + * named silence bound in the same repo for the same question was the defect; 45 s + * against 480 s is not a divergence to justify but a 10x disagreement about the + * same fact. + * + * AND THE REPO'S NUMBER IS THE ARGUED ONE. `DEFAULT_CHUNK_TIMEOUT`'s comment + * records a real observation — "mimo-v2.5-pro on MiMo Router whose cold-path TTFT + * after context rebuild can dip to ~5 minutes silent" — which is the only + * statement anywhere here about how long LEGITIMATE provider silence lasts. Our + * 45 s was 10x tighter than a value tuned to tolerate a real 5-minute silent cold + * path, so it would have declared a stall on calls the main path is explicitly + * built to survive. The false-positive risk the old constant's comment listed as + * hypothetical was in fact already measured, elsewhere, against us. + * + * WHY THE DETECTOR ITSELF IS STILL OURS, rather than deleted in favour of + * `wrapSSE`. The two observe at different points and ours sees strictly more: + * - `wrapSSE` bounds gaps between HTTP BYTES on an already-resolved + * `text/event-stream` Response, and keep-alive comments count as activity. + * - `stallWatch` bounds gaps between AI-SDK STREAM PARTS that carry model + * output, having excluded LIFECYCLE_PARTS after measuring that a + * never-answering provider still yields `start`. + * So `wrapSSE` is blind to three shapes ours catches: a fetch that never resolves + * at all (there is no Response to wrap yet), a stream that emits only keep-alive + * comments and never a token, and any provider whose adapter is not SSE-over-HTTP + * (`wrapSSE` returns the Response untouched unless the content type matches). Ours + * is blind to nothing `wrapSSE` catches. The single cost of the more sensitive + * observation point is a false positive on long legitimate silence — which is + * precisely the risk the magnitude controls, and precisely why the magnitude is + * now the tuned one rather than one we picked. + * + * `0` OR NEGATIVE DISABLES IT, because that is what the value already means to + * `provider.ts` (`chunkAbortCtl` is not created, so no bound is installed). An + * operator who turned the silence bound off for a provider turned it off for + * sampling too; second-guessing that here would make one documented switch mean + * two different things. + */ +export function chunkTimeoutFor( + config: { provider?: Record } | undefined> }, + providerID: string, +): number { + const configured = config.provider?.[providerID]?.options?.["chunkTimeout"] + // Same test provider.ts:1525 applies, so a non-number (including null) falls + // back rather than being treated as "configured". + return typeof configured === "number" ? configured : Provider.DEFAULT_CHUNK_TIMEOUT +} + +/** How much of a prompt is shown in the approval dialog and in logs. */ +const PREVIEW_LENGTH = 200 + +export type Policy = "deny" | "ask" | "allow" + +export const PERMISSION = "mcp_sampling" + +/** + * Non-standard JSON-RPC code for "a human refused". Distinct from InvalidParams + * so a server can tell "you asked wrong" from "the user said no" and stop + * retrying. -1 is the code the MCP reference servers already expect for this. + */ +export const REJECTED_CODE = -1 + +/** The SDK's own RequestTimeout code, reused so servers see a familiar value. */ +export const TIMEOUT_CODE = ErrorCode.RequestTimeout + +export interface AudioSummary { + readonly mimeType: string + readonly bytes: number +} + +export interface RequestSummary { + readonly server: string + readonly contentTypes: ReadonlyArray + readonly audio: ReadonlyArray + readonly systemPrompt?: string + readonly textPrompt?: string +} + +interface SamplingContentText { + type: "text" + text: string +} + +interface SamplingContentMedia { + type: "image" | "audio" + data: string + mimeType: string +} + +type SamplingContent = SamplingContentText | SamplingContentMedia + +export interface SamplingMessage { + role: "user" | "assistant" + content: SamplingContent | ReadonlyArray +} + +export interface CreateMessageParams { + messages: ReadonlyArray + systemPrompt?: string + includeContext?: "none" | "thisServer" | "allServers" + maxTokens: number + temperature?: number + stopSequences?: ReadonlyArray + metadata?: Record + modelPreferences?: { + hints?: ReadonlyArray<{ name?: string }> + costPriority?: number + speedPriority?: number + intelligencePriority?: number + } + tools?: unknown + toolChoice?: unknown +} + +export interface CreateMessageResult { + role: "assistant" + content: SamplingContentText + model: string + stopReason: string +} + +/** + * A structured failure that maps 1:1 onto a JSON-RPC error. Carried on the + * Effect FAILURE channel (never thrown inside Effect.fn, which would make it a + * defect that Effect.catch cannot see — see tool/session.ts:801-807). + */ +export class SamplingError extends Error { + readonly code: number + readonly data: Record | undefined + constructor(code: number, message: string, data?: Record) { + super(message) + this.name = "SamplingError" + this.code = code + this.data = data + } + toMcpError(): McpError { + return new McpError(this.code, this.message, this.data) + } +} + +function invalidParams(message: string, data?: Record) { + return new SamplingError(ErrorCode.InvalidParams, message, data) +} + +/** + * Base64 with no whitespace, correct padding, and a length that is a multiple of + * 4. Deliberately strict: a lenient decode would let malformed audio reach the + * provider and fail there with a far worse error. + */ +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ + +export function decodedByteLength(data: string): number | undefined { + if (data.length === 0) return 0 + if (data.length % 4 !== 0) return undefined + if (!BASE64.test(data)) return undefined + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0 + // Padding may only appear in the final quantum. + if (data.slice(0, -4).includes("=")) return undefined + return (data.length / 4) * 3 - padding +} + +const MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i + +export function normalizeMime(mimeType: string, modality: "image" | "audio") { + const value = mimeType.trim().split(";", 1)[0]?.trim().toLowerCase() ?? "" + if (!MIME.test(value)) return undefined + if (!value.startsWith(`${modality}/`)) return undefined + return value +} + +interface Converted { + readonly messages: ModelMessage[] + readonly requirements: ModelCapability.ContentRequirement[] + readonly summary: Omit +} + +function toArray(content: SamplingContent | ReadonlyArray): ReadonlyArray { + return Array.isArray(content) ? content : [content as SamplingContent] +} + +/** + * Validate the server's content and convert it into ai-sdk `ModelMessage`s. + * + * Media becomes a real `file` part carrying raw bytes with its media type — the + * same shape the session multimodal path produces (see message-v2.ts and the + * `mediaType` routing in tool-attachment.ts). Audio is NEVER stringified into a + * text part; a model that cannot take audio must be rejected, not fed a lie. + */ +export function convertMessages(params: CreateMessageParams): Converted | SamplingError { + if (params.tools !== undefined || params.toolChoice !== undefined) { + // Spec: the client MUST error when `tools` is present without having + // declared `sampling.tools`, which we deliberately do not declare yet. + return invalidParams("this client does not declare sampling.tools; remove tools/toolChoice", { + declaredCapabilities: { sampling: {} }, + }) + } + if (!Array.isArray(params.messages) || params.messages.length === 0) { + return invalidParams("messages must be a non-empty array") + } + if (!Number.isInteger(params.maxTokens) || params.maxTokens <= 0) { + return invalidParams("maxTokens must be a positive integer") + } + if (params.temperature !== undefined && (typeof params.temperature !== "number" || !isFinite(params.temperature))) { + return invalidParams("temperature must be a finite number") + } + + const messages: ModelMessage[] = [] + const requirements: ModelCapability.ContentRequirement[] = [] + const contentTypes = new Set() + const audio: AudioSummary[] = [] + let textPrompt: string | undefined + + const systemBytes = Buffer.byteLength(params.systemPrompt ?? "", "utf8") + if (systemBytes > ModelCapability.DEFAULT_MAX_TEXT_BYTES) { + return invalidParams("systemPrompt exceeds the maximum size", { + bytes: systemBytes, + maxBytes: ModelCapability.DEFAULT_MAX_TEXT_BYTES, + }) + } + + for (const message of params.messages) { + if (message?.role !== "user" && message?.role !== "assistant") { + return invalidParams(`unsupported message role "${String(message?.role)}"`) + } + const parts: Array< + { type: "text"; text: string } | { type: "file"; data: string; mediaType: string } + > = [] + for (const item of toArray(message.content)) { + if (item?.type === "text") { + if (typeof item.text !== "string") return invalidParams("text content must be a string") + const bytes = Buffer.byteLength(item.text, "utf8") + contentTypes.add("text") + requirements.push({ modality: "text", bytes }) + parts.push({ type: "text", text: item.text }) + if (message.role === "user" && textPrompt === undefined) textPrompt = item.text + continue + } + if (item?.type === "image" || item?.type === "audio") { + const modality = item.type + if (typeof item.data !== "string") return invalidParams(`${modality} content data must be a base64 string`) + if (typeof item.mimeType !== "string") return invalidParams(`${modality} content requires a mimeType`) + const mimeType = normalizeMime(item.mimeType, modality) + if (!mimeType) { + return invalidParams(`invalid ${modality} mimeType "${item.mimeType}"`, { mimeType: item.mimeType }) + } + const bytes = decodedByteLength(item.data) + if (bytes === undefined) return invalidParams(`${modality} content data is not valid base64`) + if (bytes === 0) return invalidParams(`${modality} content data is empty`) + contentTypes.add(modality) + if (modality === "audio") audio.push({ mimeType, bytes }) + requirements.push({ modality, mimeType, bytes }) + parts.push({ type: "file", data: item.data, mediaType: mimeType }) + continue + } + return invalidParams(`unsupported content type "${String((item as { type?: unknown })?.type)}"`) + } + if (parts.length === 0) return invalidParams("each message must carry at least one content block") + messages.push({ role: message.role, content: parts } as ModelMessage) + } + + return { + messages, + requirements, + summary: { + contentTypes: [...contentTypes], + audio, + systemPrompt: preview(params.systemPrompt), + textPrompt: preview(textPrompt), + }, + } +} + +export function preview(value: string | undefined) { + if (!value) return undefined + const clean = value.replace(/\s+/g, " ").trim() + if (clean.length <= PREVIEW_LENGTH) return clean + return `${clean.slice(0, PREVIEW_LENGTH)}…` +} + +export function policyFor(config: { mcp?: Record }, server: string): Policy { + // A nullable/absent config field arrives as undefined OR null depending on + // where it was parsed from, so discriminate on truthiness rather than on + // `=== undefined`, which would silently treat null as "configured". + const configured = config.mcp?.[server]?.sampling + if (configured === "deny" || configured === "allow" || configured === "ask") return configured + return "ask" +} + +function mapStopReason(finishReason: string | undefined, stopSequences: ReadonlyArray | undefined) { + if (finishReason === "length") return "maxTokens" + if (finishReason === "stop") return stopSequences && stopSequences.length > 0 ? "stopSequence" : "endTurn" + return finishReason ?? "endTurn" +} + +/** + * What is needed to keep a PEER's request timer alive while we work. Neither + * field is ours to invent: we are the CLIENT answering a server-initiated + * request, so the token belongs to the requester's message id and only the + * requester can mint it (`shared/protocol.js` sets + * `params._meta.progressToken = messageId`, and only when its caller passed + * `onprogress`). `serve` reads it back out of the request the SDK handed us and + * builds this; when the server did not ask for progress there is no token and + * this is `undefined`, which means we send nothing at all. + */ +export interface Liveness { + readonly progressToken: string | number + /** `extra.sendNotification` from the SDK request handler — this connection. */ + readonly send: (notification: { method: string; params: Record }) => Promise + readonly intervalMs: number +} + +/** + * Stream parts that are the SDK's own bookkeeping rather than model output. + * + * MEASURED, NOT ASSUMED, and the measurement overturned the obvious guess. Against + * a provider whose HTTP call never answers at all, `fullStream` still yields + * `start` immediately (and `abort` at the end). So "every part proves the provider + * is alive" is false: counting parts indiscriminately made a stone-dead provider + * report `1 chunk`, which destroys the single distinction this signal exists to + * draw — never started versus started and went quiet. Only parts OUTSIDE this set + * advance the activity record, so `chunks === 0` means exactly what it says. + * + * The terminal markers are excluded for the same reason and cost nothing: they + * arrive when the stream is already ending, so they could not have rescued a call + * from a stall verdict anyway. + */ +const LIFECYCLE_PARTS = new Set(["start", "start-step", "finish-step", "finish", "abort", "error"]) + +/** + * What the model call has actually produced so far, written by the stream loop in + * `handle` and read by the two watchers below. Mutable on purpose: it is the one + * piece of shared state that makes "is it hung?" answerable rather than guessed. + * + * `characters` is a COUNT, never the text — see `heartbeat` for why the text + * itself does not leave this process on the progress channel. + */ +interface StreamActivity { + /** Epoch ms of the last chunk, or of the call starting if none has arrived. */ + lastAt: number + /** Chunks of model output seen. 0 means the provider has produced nothing. */ + chunks: number + /** Characters of model text seen. */ + characters: number +} + +/** + * LIVENESS FROM REAL EVIDENCE — and what is deliberately NOT sent. + * + * The model call streams (`streamText`), so unlike the previous fixed tick this + * notification reports something observed: how many chunks the provider has + * actually produced. That upgrade matters because the two failures a peer most + * needs to tell apart are "our process died" and "the provider went quiet", and a + * tick that increments on a local timer cannot distinguish them — it keeps + * arriving, unchanged, while the provider produces nothing forever. A peer can now + * see the difference, including the specific case of a call that never started at + * all, which gets its own wording. + * + * WHY THE MODEL'S TEXT IS NOT IN `message`, even though we now have it. Three + * reasons, and the middle one is the one that decided it. + * 1. CHATTINESS. Deltas arrive far faster than this interval; forwarding each + * would turn a keepalive into a second, unasked-for output stream. What goes + * out is coalesced to one notification per interval no matter the delta rate. + * 2. IT WOULD DELIVER OUTPUT THAT A FAILED REQUEST NEVER DELIVERS. The response + * contract is a single `CreateMessageResult`: if this request later stalls, + * times out or is cancelled, the server is told it failed and receives NO + * text. Streaming partial content on the progress channel would hand it a + * prefix of an answer the contract says it never got — a disclosure that + * exists only in the failure case, which is the worst place to invent one. + * 3. A SERVER THAT ASKED FOR PROGRESS DID NOT ASK FOR CONTENT. `onprogress` is + * how a peer says "tell me you are alive"; MCP has no partial-result channel, + * and reading `message` as one would be us deciding on the peer's behalf. + * What IS disclosed is the running length. That is metadata, not content, and the + * server learns the exact length seconds later from the result anyway — but it is + * a real if small disclosure and is named here rather than glossed over. + * + * `progress` stays a monotonic TICK, not the chunk count: the spec asks only that + * the value increase, and a chunk count does not increase during exactly the quiet + * stretch a peer most needs a notification for. `total` IS STILL OMITTED, because + * streaming does not tell us how many chunks are coming either — a fraction is + * still not computable, so none is implied. + * + * Runs forever and never fails: each send is ignored, because a peer that cannot + * receive a notification must not thereby kill the model call. Raced against the + * model call with `raceFirst` — the model settling first (success OR failure) + * interrupts this. + */ +function heartbeat(liveness: Liveness, activity: StreamActivity): Effect.Effect { + let tick = 0 + return Effect.forever( + Effect.sleep(liveness.intervalMs).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: () => + liveness.send({ + method: "notifications/progress", + params: { + progressToken: liveness.progressToken, + progress: ++tick, + message: + activity.chunks === 0 + ? "sampling: model call in flight, no output yet" + : `sampling: model streaming, ${activity.chunks} chunks / ${activity.characters} characters so far`, + }, + }), + catch: (error) => error, + }).pipe(Effect.ignore), + ), + ), + ) +} + +/** + * THE STALL DETECTOR — now the ONLY bound on the model call, which is why the total + * bounds could go. + * + * Fails as soon as `stallMs` has passed with no chunk arriving. The clock covers + * BOTH the wait for the first chunk and every gap between later chunks, because "no + * output at all" is the same symptom in both places; every arriving chunk resets it. + * Unlike a total bound this is a claim about the provider rather than about our + * patience: the stream stopped, and that is a fact we watched happen instead of a + * deadline we picked. Raced against the model call with `raceFirst`, so this failure + * interrupts the call fiber, which aborts the provider through the signal + * composed in `handle`. + * + * NOT gated on the peer having asked for progress. The heartbeat is (an + * unsolicited notification is an error on the peer's side); detecting our own + * stalled provider is not the peer's business and happens regardless. It is also + * the only reaper for the first server-initiated request of a connection, whose + * cancellation the SDK drops — see the top-of-file comment. + * + * Polling rather than a per-chunk timer, because the chunk loop lives inside a + * promise and this has to observe it from outside without restructuring it. The + * poll interval only bounds detection LATENCY, never correctness: a stall is + * reported at most one poll late, never early, since the comparison is against a + * timestamp the loop wrote. + */ +function stallWatch( + activity: StreamActivity, + stallMs: number, + onStalled: () => SamplingError, +): Effect.Effect { + const poll = Math.max(25, Math.min(250, Math.floor(stallMs / 4))) + return Effect.forever( + Effect.sleep(poll).pipe( + Effect.flatMap(() => (Date.now() - activity.lastAt >= stallMs ? Effect.fail(onStalled()) : Effect.void)), + ), + ) +} + +export interface HandleInput { + readonly server: string + readonly params: CreateMessageParams + /** + * Session the approval prompt belongs to. Absent when no turn is in flight for + * this client; under the `ask` policy that fails closed rather than raising a + * prompt no UI is listening to. + */ + readonly sessionID: SessionID | undefined + readonly signal?: AbortSignal + /** + * How long the model may produce nothing before the call is declared stalled. + * Defaults to the provider's `chunkTimeout` — see `chunkTimeoutFor`. This is the + * ONLY bound on the model call; there is deliberately no total one. + */ + readonly chunkTimeoutMs?: number + /** Absent when the server did not ask for progress; then nothing is emitted. */ + readonly liveness?: Liveness +} + +/** + * Run one sampling request end to end. Fails with `SamplingError` only — the + * caller turns that into a JSON-RPC error response. + */ +export const handle = Effect.fn("MCP.sampling.handle")(function* (input: HandleInput) { + const started = Date.now() + const cfgSvc = yield* Config.Service + const provider = yield* Provider.Service + const permission = yield* Permission.Service + const cfg = yield* cfgSvc.get() + + const policy = policyFor(cfg as never, input.server) + // TWO controls gate sampling and a `deny` from either one wins: the per-server + // `mcp..sampling` policy, and the standard `permission.mcp_sampling` + // ruleset. Evaluating the ruleset HERE rather than leaning on permission.ask + // is what makes that true — `allow` skips the ask entirely, so an explicit + // ruleset deny would otherwise never be consulted at all. Same precedence the + // permission service applies internally (permission/index.ts:243-247): a + // ruleset deny is not out-rankable by a more permissive setting elsewhere. + const ruleset = Permission.fromConfig(cfg.permission ?? {}) + const ruleDenied = Permission.evaluate(PERMISSION, input.server, ruleset).action === "deny" + if (policy === "deny" || ruleDenied) { + return yield* Effect.fail( + new SamplingError(REJECTED_CODE, `sampling is denied for MCP server "${input.server}"`, { + server: input.server, + policy, + deniedBy: policy === "deny" ? "mcp.sampling" : "permission.mcp_sampling", + }), + ) + } + + const converted = convertMessages(input.params) + if (converted instanceof SamplingError) return yield* Effect.fail(converted) + + const summary: RequestSummary = { server: input.server, ...converted.summary } + + // Model selection: capability + credentials FIRST, hints only to rank. + const providers = yield* provider.list() + const configured = Object.values(providers).flatMap((info) => Object.values(info.models)) + const fallbackRef = yield* provider.defaultModel().pipe(Effect.catchCause(() => Effect.succeed(undefined))) + const fallback = fallbackRef + ? yield* provider + .getModel(fallbackRef.providerID, fallbackRef.modelID) + .pipe(Effect.catchDefect(() => Effect.succeed(undefined)), Effect.catchCause(() => Effect.succeed(undefined))) + : undefined + + const selection = ModelCapability.selectModel({ + models: configured, + requirements: converted.requirements, + hints: input.params.modelPreferences?.hints, + fallback, + }) + + if (!selection.ok) { + return yield* Effect.fail( + new SamplingError(ErrorCode.InvalidParams, "no configured model can accept this sampling request", { + server: input.server, + required: selection.requirements.map((item) => ({ + modality: item.modality, + mimeType: item.mimeType, + bytes: item.bytes, + })), + rejected: selection.rejections.map((item) => ({ + model: item.model, + reason: ModelCapability.describeRejection(item.reason), + })), + }), + ) + } + + const model = selection.model + const modelRef = ModelCapability.modelRef(model) + // Resolved HERE and not at the top of `handle` because it is the SELECTED + // provider's setting: which provider runs a sampling request is decided by + // capability matching, so its silence bound is not knowable before that. + const chunkTimeoutMs = input.chunkTimeoutMs ?? chunkTimeoutFor(cfg as never, model.providerID) + + if (policy === "ask") { + const sessionID = input.sessionID + if (!sessionID) { + // Fail closed: an `ask` with no session would publish a prompt no client + // is listening for, and waiting on it would hang the server's request. + return yield* Effect.fail( + new SamplingError( + REJECTED_CODE, + `sampling for MCP server "${input.server}" needs approval but no active session is available`, + { server: input.server, model: modelRef, policy }, + ), + ) + } + yield* permission + .ask( + { + sessionID, + permission: PERMISSION, + patterns: [input.server], + always: [input.server], + ruleset, + metadata: { + server: input.server, + model: modelRef, + requestedModel: input.params.modelPreferences?.hints?.map((hint) => hint.name).filter(Boolean) ?? [], + contentTypes: summary.contentTypes, + audio: summary.audio, + systemPrompt: summary.systemPrompt, + textPrompt: summary.textPrompt, + maxTokens: input.params.maxTokens, + }, + }, + input.signal, + ) + .pipe( + Effect.catch((error) => + Effect.fail( + new SamplingError(REJECTED_CODE, `the user declined sampling for MCP server "${input.server}"`, { + server: input.server, + model: modelRef, + reason: error._tag, + }), + ), + ), + // NO BOUND ON THE APPROVAL WAIT, deliberately — see the top-of-file comment. + // The ordinary interactive ask in `permission/index.ts` has none either, and + // this ask is the ordinary kind: no `forward`, and `mcp_sampling` is not in + // `FORCED_ASK`. `input.signal` is passed above, so a peer cancellation ends + // the wait promptly; the operator answering ends it; `cancelAll` ends it when + // the client closes. + ) + } + + const language = yield* provider + .getLanguage(model) + .pipe( + Effect.catchCause((cause) => + Effect.fail( + new SamplingError(ErrorCode.InternalError, "failed to initialise the selected model", { + model: modelRef, + // Cause.pretty of a plain Error renders only its message, so no + // provider credential can ride along here. + detail: Cause.pretty(cause).split("\n")[0], + }), + ), + ), + ) + + // The signal actually handed to the provider. Assigned by `tryPromise` below + // and read by its `catch`, which has to tell "we aborted this" from "the + // provider genuinely failed" without assuming which source aborted. + let providerSignal: AbortSignal | undefined + + // Shared with the two watchers raced against the call below. Seeded now, reset + // when the request is actually issued, and advanced by every chunk. + const activity: StreamActivity = { lastAt: Date.now(), chunks: 0, characters: 0 } + + const call = Effect.tryPromise({ + try: (fiberSignal: AbortSignal) => { + // COMPOSE both abort sources. `fiberSignal` is aborted whenever this fiber + // is interrupted, which covers the stall detector below and `cancelAll`; on + // its own, neither of those + // reaches the provider, because interrupting a fiber does not cancel a + // promise already in flight inside it. `input.signal` is the MCP SDK's + // per-request signal and covers a server-issued cancellation. Either one + // must stop the HTTP call, so the provider gets the union of the two, not + // just one of them. + providerSignal = input.signal ? AbortSignal.any([fiberSignal, input.signal]) : fiberSignal + const stream = streamText({ + model: language, + system: input.params.systemPrompt, + messages: converted.messages, + maxOutputTokens: Math.min(input.params.maxTokens, ProviderTransform.maxOutputTokens(model)), + temperature: model.capabilities.temperature ? input.params.temperature : undefined, + stopSequences: input.params.stopSequences ? [...input.params.stopSequences] : undefined, + providerOptions: ProviderTransform.providerOptions(model, {}), + headers: { ...model.headers, "User-Agent": `mimocode/${InstallationVersion}` }, + abortSignal: providerSignal, + maxRetries: 1, + // `streamText` reports provider failures as an `error` PART rather than by + // rejecting, and its default handler logs them. The loop below rethrows + // that part, which is what puts the failure back on the path `catch` + // already maps, so this handler exists only to stop the duplicate log. + onError: () => {}, + }) + // WHY THE STREAM IS ASSEMBLED HERE AND NOT RETURNED. The response contract is + // a single `CreateMessageResult` — `sampling/createMessage` has one reply and + // JSON-RPC has no streaming response — so streaming is an INTERNAL change: + // it buys an observable stall signal and real liveness, and changes nothing a + // server receives. The text is concatenated verbatim in arrival order. + return (async () => { + activity.lastAt = Date.now() + let text = "" + for await (const part of stream.fullStream) { + // Same shape as the in-tree consumers (session/goal.ts): an `error` part + // is the provider failing, so rethrow it and let `catch` classify it + // exactly as it classified a rejected `generateText`. + if (part.type === "error") throw part.error + if (part.type === "text-delta") { + text += part.text + activity.characters += part.text.length + } + // Only genuine model output counts as life. `start` arrives even from a + // provider that never answers, so lifecycle parts neither increment the + // count nor reset the stall clock — see LIFECYCLE_PARTS. + if (!LIFECYCLE_PARTS.has(part.type)) { + activity.chunks += 1 + activity.lastAt = Date.now() + } + } + return { text, finishReason: await stream.finishReason } + })() + }, + catch: (error) => { + const message = error instanceof Error ? error.message : String(error) + if (providerSignal?.aborted ?? input.signal?.aborted) { + return new SamplingError(ErrorCode.RequestTimeout, "sampling was cancelled", { server: input.server }) + } + return new SamplingError(ErrorCode.InternalError, "the model provider failed to complete sampling", { + server: input.server, + model: modelRef, + detail: message, + }) + }, + }) + + // THE STALL DETECTOR, raced first and NOT gated on the peer asking for progress. + // `raceFirst` is "first to SETTLE", so this failing interrupts the call fiber and + // aborts the provider; `Effect.race` would be wrong because it waits for a losing + // side to fail and neither side here obliges. + // + // Skipped entirely at `<= 0`, which is what that value already means to + // `provider.ts` — see `chunkTimeoutFor`. Then the call has no bound of ours at + // all, exactly as a `chunkTimeout: 0` conversation has none on the main path. + const watched = + chunkTimeoutMs > 0 + ? Effect.raceFirst( + call, + stallWatch( + activity, + chunkTimeoutMs, + () => + new SamplingError(TIMEOUT_CODE, "sampling stalled: the model produced no output", { + server: input.server, + model: modelRef, + // Kept as its own phase now that `"model"` and `"total"` are gone: + // it says output STOPPED, which is a claim about the provider, and + // it is the only expiry the model call can now produce. + phase: "stall", + timeout: chunkTimeoutMs, + // The observability payoff, and the reason this is not just a + // shorter timeout: 0 says the provider never produced anything, + // non-zero says it started and then went quiet. + chunks: activity.chunks, + characters: activity.characters, + }), + ), + ) + : call + + // KEEPALIVE, and only if the server asked for it. `raceFirst` again, for the same + // reason: the model call winning with a failure — including a stall — still has + // to interrupt the heartbeat, which never settles and so can never win. + const kept = input.liveness ? Effect.raceFirst(watched, heartbeat(input.liveness, activity)) : watched + + const result = yield* kept + + // The model's text is returned verbatim: no summarising, no rewriting. + const text = result.text ?? "" + log.info("sampling completed", { + server: input.server, + model: modelRef, + via: selection.via, + contentTypes: summary.contentTypes, + audioBytes: summary.audio.reduce((total, item) => total + item.bytes, 0), + duration: Date.now() - started, + status: "ok", + }) + + return { + role: "assistant" as const, + content: { type: "text" as const, text }, + model: modelRef, + stopReason: mapStopReason(result.finishReason, input.params.stopSequences), + } satisfies CreateMessageResult +}) + +/** + * Session of the turn a client is currently serving, used to address the sampling + * approval prompt at the right session. Written when a tool call starts and read + * by the sampling handler, which by definition runs while that call is still in + * flight. A WeakMap so a discarded client takes its entry with it. + */ +const activeSessions = new WeakMap() + +export function setActiveSession(client: object, sessionID: SessionID) { + activeSessions.set(client, sessionID) +} + +/** In-flight sampling fibers per client, interrupted when the client goes away. */ +const inFlight = new WeakMap>>() + +/** + * Interrupt every sampling request still running for a client. The interrupt + * aborts the in-flight provider call too, because `handle` hands the provider a + * signal derived from its own fiber — see the abort composition there. + */ +export function cancelAll(client: object) { + const fibers = inFlight.get(client) + if (!fibers) return Effect.void + const pending = [...fibers] + fibers.clear() + return Effect.forEach(pending, (fiber) => Fiber.interrupt(fiber).pipe(Effect.ignore), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.ignore) +} + +/** How many sampling requests are currently running for a client. Test-facing. */ +export function inFlightCount(client: object) { + return inFlight.get(client)?.size ?? 0 +} + +/** + * The part of the SDK's request-handler `extra` this module reads. Deliberately + * `unknown` for everything but the signal: `_meta` and `sendNotification` are + * typed on the SDK side against a notification union that is generic over the + * schema, and naming those types here would couple the module to SDK internals + * for no gain — the two are narrowed at the use site instead. + */ +export interface SamplingRequestExtra { + signal?: AbortSignal + /** The request's own `params._meta`, passed through verbatim by `_onrequest`. */ + _meta?: unknown + /** Sends a notification on THIS request's connection, tagged to its id. */ + sendNotification?: unknown +} + +/** + * The subset of the MCP `Client` surface this module drives. Typed loosely on + * purpose: the SDK's own `setRequestHandler` signature is generic over the Zod + * schema and infers a result type we satisfy structurally, so pinning it exactly + * here would only couple this module to SDK internals. + */ +export interface SamplingClient { + setRequestHandler( + schema: typeof CreateMessageRequestSchema, + handler: (request: { params?: unknown }, extra?: SamplingRequestExtra) => Promise, + ): void +} + +/** + * Read the progress token the REQUESTER minted, if it minted one. + * + * We are the client answering a server-initiated request, so we never choose this + * value. The SDK's requester side writes it only when its caller asked for + * progress (`shared/protocol.js`: `if (options?.onprogress) { ... _meta: { ..., + * progressToken: messageId } }`) and the responder side hands the handler that + * same object (`_meta: request.params?._meta`). NO TOKEN THEREFORE MEANS THE + * SERVER DID NOT ASK FOR PROGRESS, and we must send nothing at all — an + * unsolicited notification hits `_onprogress`'s "unknown token" branch and is + * reported to the peer as an error. + */ +function progressTokenOf(extra: SamplingRequestExtra | undefined) { + const meta = extra?._meta + if (typeof meta !== "object" || meta === null) return undefined + const token = (meta as { progressToken?: unknown }).progressToken + return typeof token === "string" || typeof token === "number" ? token : undefined +} + +export interface Bridge { + readonly fork: (effect: Effect.Effect) => Fiber.Fiber +} + +/** + * Register the server->client `sampling/createMessage` handler on a connected + * client. + * + * DEADLOCK AVOIDANCE. Two independent facts make a nested sampling request safe + * while we are parked on that same server's `tools/call`: + * + * 1. The SDK dispatches inbound requests from the transport's `onmessage` + * WITHOUT awaiting the handler (sdk/shared/protocol.js `_onrequest`), so our + * work never blocks the read loop that must later deliver the tool result. + * 2. Our work runs through `bridge.fork`, i.e. a FRESH ROOT FIBER that shares no + * fiber, lock or scope with the fiber awaiting `client.callTool`. + * + * Both directions therefore make progress independently. + * + * `chunkTimeoutMs` bounds how long the model may produce NOTHING and defaults to the + * selected provider's `chunkTimeout` (see `chunkTimeoutFor`); `livenessIntervalMs` + * sets the keepalive cadence. Production passes neither. `chunkTimeoutMs` is a + * parameter so the stall path can be driven in a test without waiting minutes. + * + * NO WALL-CLOCK BOUND IS APPLIED HERE AT ALL — not on the whole request, not on the + * model call, not on the approval wait. Each of the three that used to exist was a + * number with no precedent in this repo; the top-of-file comment records what the + * repo does instead in each case and what consequently stops being caught. + */ +export function serve( + server: string, + client: SamplingClient, + bridge: Bridge, + livenessIntervalMs: number = DEFAULT_LIVENESS_INTERVAL, + chunkTimeoutMs?: number, +) { + client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { + const params = (request.params ?? {}) as CreateMessageParams + // KEEPALIVE WIRING. Both halves come from the SDK and neither is ours to + // fabricate: the token off the request's `_meta`, the sender off `extra`. If + // either is missing the server did not ask for progress and `liveness` stays + // undefined, which makes `handle` emit nothing. + const progressToken = progressTokenOf(extra) + const send = extra?.sendNotification + const liveness: Liveness | undefined = + progressToken !== undefined && typeof send === "function" + ? { progressToken, send: send as Liveness["send"], intervalMs: livenessIntervalMs } + : undefined + const effect = handle({ + server, + params, + sessionID: activeSessions.get(client), + signal: extra?.signal, + chunkTimeoutMs, + liveness, + }).pipe(Effect.exit) + + let fibers = inFlight.get(client) + if (!fibers) { + fibers = new Set() + inFlight.set(client, fibers) + } + const fiber = bridge.fork(effect) + fibers.add(fiber as Fiber.Fiber) + try { + const exit = await Effect.runPromise(Fiber.join(fiber)) + if (Exit.isSuccess(exit)) return exit.value as never + // `handle` puts SamplingError on the FAILURE channel, so squash returns the + // instance itself and `instanceof` survives. A cancelled fiber and a + // genuine defect both land here and become explicit errors. + const failure = Cause.squash(exit.cause) + if (failure instanceof SamplingError) throw failure.toMcpError() + if (Cause.hasInterrupts(exit.cause)) { + log.info("sampling cancelled", { server, status: "cancelled" }) + throw new McpError(TIMEOUT_CODE, "sampling was cancelled", { server }) + } + log.error("sampling failed", { server, status: "error" }) + throw new McpError(ErrorCode.InternalError, "sampling failed") + } finally { + fibers.delete(fiber as Fiber.Fiber) + } + }) +} + +export * as McpSampling from "./sampling" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 826a05b9b..efc66505c 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -394,14 +394,27 @@ export const layer = Layer.effect( // Spec ③ P3: race against caller's abortSignal so a stranded ask // doesn't block forever when the surrounding scope is interrupted. // NOTE: Effect.callback (not Effect.promise) — when Deferred.await - // wins the race, Effect.race interrupts the callback and runs the + // wins the race, the race interrupts the callback and runs the // cleanup returned from the body, which removes the addEventListener. // Effect.promise has no such hook: listener leaks for the lifetime // of the AbortSignal + unhandled-rejection when the eventual abort // tries to reject the already-dead Promise. + // + // raceFirst, NOT race. `Effect.race` resolves with the first + // *success* and treats a failure as "not a winner", so it keeps + // waiting on the loser; `Effect.raceFirst` resolves with the first + // side to *complete*, success or failure. A human rejection FAILS + // this Deferred, so under `race` the ask parked forever whenever an + // abortSignal was passed — the abort side never settles on its own + // and there is nothing left to win. Measured on effect@4.0.0-beta.48: + // race(failed Deferred, never) never settles; raceFirst yields the + // RejectedError. Interruption still composes: an interrupt of this + // fiber exits with a cause for which Cause.hasInterrupts is true + // rather than being flattened into a plain failure, which is why + // this is not done by wrapping a side in Effect.exit. const deferredAwait = Deferred.await(deferred) const main = abortSignal - ? Effect.race( + ? Effect.raceFirst( deferredAwait, Effect.callback((resume) => { const onAbort = () => { @@ -423,8 +436,12 @@ export const layer = Layer.effect( // A forwarded ask that no approver resolves must still terminate (deny), // never hang. Race the bounded timeout; the grant path above already // resolved the Deferred, so it wins instantly when pre-authorized. + // raceFirst for the same reason as above: under `race` a forwarded ask + // that the approver DENIED failed the Deferred, which counted as no + // winner, so the caller waited out the whole FORWARD_DENY_TIMEOUT_MS + // before seeing the rejection it already had. let guarded = input.forward - ? Effect.race( + ? Effect.raceFirst( main, Effect.sleep(`${FORWARD_DENY_TIMEOUT_MS} millis`).pipe( Effect.andThen(() => Deferred.fail(deferred, new RejectedError())), @@ -438,8 +455,10 @@ export const layer = Layer.effect( // Bound it with a timeout; CorrectedError (not RejectedError) so the // processor does NOT set ctx.blocked — the model sees an error result with // actionable feedback and the session loop continues to the next step. - // NOTE: Effect.race with a permanently-blocked Deferred hangs under the - // current Effect v4 beta, so use Effect.timeoutOrElse instead. + // NOTE: keep Effect.timeoutOrElse here rather than racing a failing + // sleep. The reason is the same "a failure is not a winner" rule that + // forced raceFirst above: a timeout side that FAILS never wins an + // Effect.race, so the race would sit on the still-blocked Deferred. if (s.skipAll && forced) { const timeoutMs = skipAllForcedAskTimeoutMs() guarded = Effect.timeoutOrElse(guarded, { diff --git a/packages/opencode/src/provider/capability-registry.ts b/packages/opencode/src/provider/capability-registry.ts new file mode 100644 index 000000000..448ad8c2c --- /dev/null +++ b/packages/opencode/src/provider/capability-registry.ts @@ -0,0 +1,336 @@ +import type { Provider } from "@/provider" + +/** + * Model Capability Registry. + * + * MCP itself publishes no model list and no modality discovery, so a + * `sampling/createMessage` request cannot be routed by guessing. This registry + * is the single place that answers "can THIS model, through THIS adapter, + * actually accept THIS content?". + * + * Two independent gates are ANDed: + * + * 1. The MODEL gate — `model.capabilities.input.{text,image,audio}`, sourced + * from models.dev metadata or the user's own `/modalities` config. + * 2. The ADAPTER gate — whether the ai-sdk package behind the model can + * actually serialize that media. A model may accept audio while the adapter + * wired up for it cannot carry audio bytes; sending anyway would silently + * degrade the request. + * + * The adapter gate is deliberately TRI-STATE. "unsupported" is a claim we can + * substantiate; "unknown" means we have no evidence either way and refuse to + * invent one. Both are ineligible (fail closed) but they are reported + * distinctly so an operator can tell "this cannot work" from "we do not know". + */ + +export type Modality = "text" | "image" | "audio" + +export type Support = "supported" | "unsupported" | "unknown" + +/** What a single adapter accepts for a single modality. */ +export interface ModalityDeclaration { + readonly support: Support + /** Accepted MIME types. `"any"` means every MIME within the modality prefix. */ + readonly mimeTypes: ReadonlyArray | "any" + /** Per-item cap on DECODED bytes. */ + readonly maxBytes: number +} + +export interface AdapterDeclaration { + readonly text: ModalityDeclaration + readonly image: ModalityDeclaration + readonly audio: ModalityDeclaration + /** Why this declaration reads the way it does. Surfaced in errors and docs. */ + readonly evidence: string +} + +/** + * Client-side safety cap on inline media, NOT a claim about any provider's real + * limit (no provider we wire up documents one we can read from metadata). It + * exists so a hostile or buggy MCP server cannot push an unbounded payload + * through the sampling path. 20 MiB comfortably clears the target use case: 30s + * of 16 kHz mono 16-bit PCM WAV is ~0.92 MiB. + */ +export const DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024 + +/** Text is capped far lower — a prompt, not a payload. */ +export const DEFAULT_MAX_TEXT_BYTES = 1 * 1024 * 1024 + +const SAFE_IMAGE_MIMES = ["image/jpeg", "image/png", "image/gif", "image/webp"] +// Mirrors OPENAI_AUDIO_MIMES in src/session/tool-attachment.ts — the set the +// OpenAI-compatible chat adapter can serialize as input_audio. +const OPENAI_AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/mpeg"] + +const TEXT_SUPPORTED: ModalityDeclaration = { + support: "supported", + mimeTypes: "any", + maxBytes: DEFAULT_MAX_TEXT_BYTES, +} + +const IMAGE_SUPPORTED: ModalityDeclaration = { + support: "supported", + mimeTypes: SAFE_IMAGE_MIMES, + maxBytes: DEFAULT_MAX_MEDIA_BYTES, +} + +function absent(mimeTypes: ReadonlyArray | "any" = []): ModalityDeclaration { + return { support: "unsupported", mimeTypes, maxBytes: 0 } +} + +function unknown(): ModalityDeclaration { + return { support: "unknown", mimeTypes: [], maxBytes: 0 } +} + +/** + * Per-adapter declarations, keyed by the ai-sdk npm package on `model.api.npm`. + * + * Every audio verdict below was verified by driving the INSTALLED adapter with an + * `audio/*` file part and observing the serialized request body (or the thrown + * `functionality not supported` error). Those observations are locked in by + * test/provider/capability-registry-wire.test.ts, which fails if an adapter + * upgrade changes the behaviour this table asserts. The verdicts also agree with + * the routing logic this repo already ships in `src/session/tool-attachment.ts`. + */ +const ADAPTERS: Record = { + "@ai-sdk/openai-compatible": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: OPENAI_AUDIO_MIMES, maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + // Observed: wav/mp3/mpeg serialize to `input_audio`; flac and ogg throw + // "'audio media type ...' functionality not supported". + evidence: "@ai-sdk/openai-compatible@3 serializes wav/mp3/mpeg as input_audio and rejects other audio", + }, + "@ai-sdk/google": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: "any", maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + // Observed: any audio/* passes through as `inlineData` with its MIME intact. + evidence: "@ai-sdk/google passes any audio/* through as inlineData", + }, + "@ai-sdk/google-vertex": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: { support: "supported", mimeTypes: "any", maxBytes: DEFAULT_MAX_MEDIA_BYTES }, + evidence: "@ai-sdk/google-vertex shares the @ai-sdk/google content conversion", + }, + "@ai-sdk/anthropic": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + // Observed: an audio/wav part throws "'media type: audio/wav' functionality + // not supported" while image/png serializes fine. Known-absent, not unproven. + evidence: "@ai-sdk/anthropic throws 'media type: audio/wav' functionality not supported", + }, + "@ai-sdk/google-vertex/anthropic": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + evidence: "@ai-sdk/google-vertex/anthropic shares the @ai-sdk/anthropic content conversion", + }, + "@ai-sdk/amazon-bedrock": { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: absent(), + evidence: "tool-attachment.ts:49-53,69-71 exclude @ai-sdk/amazon-bedrock from every audio route", // no direct wire probe; routing logic is the evidence + }, +} + +/** + * Adapters with no entry above. Text and image are still declared supported + * because every ai-sdk language model carries text, and image parts are a + * baseline `LanguageModelV3` file part that adapters reject loudly rather than + * silently mangle. Audio is `unknown`: we have no evidence, so we say so. + */ +const UNDECLARED: AdapterDeclaration = { + text: TEXT_SUPPORTED, + image: IMAGE_SUPPORTED, + audio: unknown(), + evidence: "no capability declaration for this adapter; audio support is unproven, not disproven", +} + +export function adapterDeclaration(npm: string | undefined): AdapterDeclaration { + if (!npm) return UNDECLARED + return ADAPTERS[npm] ?? UNDECLARED +} + +/** Adapter packages carrying an explicit declaration. Exported for docs/tests. */ +export function declaredAdapters(): ReadonlyArray { + return Object.keys(ADAPTERS) +} + +function modelGate(model: Provider.Model, modality: Modality): boolean { + if (modality === "text") return model.capabilities.input.text + if (modality === "image") return model.capabilities.input.image + return model.capabilities.input.audio +} + +/** + * The effective declaration for a model: the adapter declaration narrowed by + * the model's own declared input modalities. A model that does not declare a + * modality is `unsupported` for it regardless of what its adapter could carry. + */ +export function modelDeclaration(model: Provider.Model, modality: Modality): ModalityDeclaration { + const adapter = adapterDeclaration(model.api.npm)[modality] + if (!modelGate(model, modality)) { + return { support: "unsupported", mimeTypes: [], maxBytes: 0 } + } + return adapter +} + +/** One piece of content a sampling request wants to send. */ +export interface ContentRequirement { + readonly modality: Modality + readonly mimeType?: string + /** Decoded size in bytes. */ + readonly bytes: number +} + +export type RejectionReason = + | { readonly kind: "modality-unsupported"; readonly modality: Modality } + | { readonly kind: "modality-unknown"; readonly modality: Modality } + | { readonly kind: "mime-unsupported"; readonly modality: Modality; readonly mimeType: string } + | { + readonly kind: "too-large" + readonly modality: Modality + readonly bytes: number + readonly maxBytes: number + } + +export interface Rejection { + readonly model: string + readonly reason: RejectionReason +} + +export function describeRejection(reason: RejectionReason): string { + if (reason.kind === "modality-unsupported") return `does not accept ${reason.modality} input` + if (reason.kind === "modality-unknown") return `has no declared ${reason.modality} support` + if (reason.kind === "mime-unsupported") return `does not accept ${reason.mimeType}` + return `content is ${reason.bytes} bytes, over the ${reason.maxBytes} byte limit for ${reason.modality}` +} + +export function modelRef(model: Provider.Model): string { + return `${model.providerID}/${model.id}` +} + +/** + * Check one model against every content requirement. Returns the first reason + * the model cannot serve the request, or `undefined` when it can. + */ +export function rejectionFor( + model: Provider.Model, + requirements: ReadonlyArray, +): RejectionReason | undefined { + for (const requirement of requirements) { + const declaration = modelDeclaration(model, requirement.modality) + if (declaration.support === "unknown") { + return { kind: "modality-unknown", modality: requirement.modality } + } + if (declaration.support === "unsupported") { + return { kind: "modality-unsupported", modality: requirement.modality } + } + if (requirement.mimeType && declaration.mimeTypes !== "any") { + const mime = requirement.mimeType.toLowerCase() + if (!declaration.mimeTypes.some((item) => item.toLowerCase() === mime)) { + return { kind: "mime-unsupported", modality: requirement.modality, mimeType: requirement.mimeType } + } + } + if (requirement.bytes > declaration.maxBytes) { + return { + kind: "too-large", + modality: requirement.modality, + bytes: requirement.bytes, + maxBytes: declaration.maxBytes, + } + } + } + return undefined +} + +export interface ModelHint { + readonly name?: string +} + +export interface SelectionInput { + /** Every model the user has actually configured credentials for. */ + readonly models: ReadonlyArray + readonly requirements: ReadonlyArray + /** Advisory, in server-preference order. Ranks eligible models; never widens. */ + readonly hints?: ReadonlyArray + /** Existing model-selection strategy's answer, used when no hint matches. */ + readonly fallback?: Provider.Model +} + +export type SelectionResult = + | { + readonly ok: true + readonly model: Provider.Model + /** How the winner was chosen. */ + readonly via: "hint" | "fallback" | "first-eligible" + /** The hint that matched, when `via` is "hint". */ + readonly hint?: string + } + | { + readonly ok: false + /** Every configured model and why it was rejected. */ + readonly rejections: ReadonlyArray + readonly requirements: ReadonlyArray + } + +function hintMatches(model: Provider.Model, hint: string): boolean { + const needle = hint.toLowerCase() + const id = model.id.toLowerCase() + const ref = modelRef(model).toLowerCase() + const name = model.name.toLowerCase() + if (id === needle || ref === needle || name === needle) return true + // The spec treats a hint name as a substring that MAY match loosely. + return id.includes(needle) || ref.includes(needle) || name.includes(needle) +} + +function exactHintMatch(model: Provider.Model, hint: string): boolean { + const needle = hint.toLowerCase() + return model.id.toLowerCase() === needle || modelRef(model).toLowerCase() === needle +} + +function stableOrder(models: ReadonlyArray): Provider.Model[] { + return [...models].sort((a, b) => modelRef(a).localeCompare(modelRef(b))) +} + +/** + * FILTER THEN RANK. Eligibility is decided purely by capability + configured + * credentials; only the surviving set is then ordered by the server's hints. + * A hint can never make an ineligible model eligible. + */ +export function selectModel(input: SelectionInput): SelectionResult { + const eligible: Provider.Model[] = [] + const rejections: Rejection[] = [] + + for (const model of stableOrder(input.models)) { + const reason = rejectionFor(model, input.requirements) + if (reason) rejections.push({ model: modelRef(model), reason }) + else eligible.push(model) + } + + if (eligible.length === 0) { + return { ok: false, rejections, requirements: input.requirements } + } + + for (const hint of input.hints ?? []) { + const name = hint.name + if (!name) continue + const exact = eligible.find((model) => exactHintMatch(model, name)) + if (exact) return { ok: true, model: exact, via: "hint", hint: name } + const loose = eligible.find((model) => hintMatches(model, name)) + if (loose) return { ok: true, model: loose, via: "hint", hint: name } + } + + // No hint landed on an eligible model: defer to the existing selection + // strategy when its answer is itself eligible, else take the first eligible + // model in a deterministic order. + const fallback = input.fallback + if (fallback && eligible.some((model) => modelRef(model) === modelRef(fallback))) { + return { ok: true, model: fallback, via: "fallback" } + } + return { ok: true, model: eligible[0], via: "first-eligible" } +} + +export * as ModelCapability from "./capability-registry" diff --git a/packages/opencode/src/provider/index.ts b/packages/opencode/src/provider/index.ts index 9e8891144..2864e33e6 100644 --- a/packages/opencode/src/provider/index.ts +++ b/packages/opencode/src/provider/index.ts @@ -3,3 +3,4 @@ export * as ProviderAuth from "./auth" export * as ProviderError from "./error" export * as ModelsDev from "./models" export * as ProviderTransform from "./transform" +export * as ModelCapability from "./capability-registry" diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md index 895c8a29e..e91695cb6 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md @@ -56,6 +56,7 @@ Read only the reference needed for the request, but read it before changing file - Task-oriented usage and setup: @reference/guide.md - CLI and slash commands: @reference/commands.md - Permission rules: @reference/permissions.md +- MCP client-side sampling (servers borrowing your model, audio transcription): @reference/mcp-sampling.md - Dynamic workflows: @reference/workflows.md ## How-To Guide diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/config.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/config.md index ce9dda165..5b1a8bc0a 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/config.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/config.md @@ -98,7 +98,7 @@ Prefer a markdown file (`.mimocode/agent/.md`, body = system prompt) for d | Key | Purpose | |-----|---------| | `skills` | `paths[]` extra skill folders + `urls[]` remote skill indexes | -| `mcp` | MCP servers: `local` (command/env) or `remote` (url/headers/oauth); `{ "enabled": false }` disables one | +| `mcp` | MCP servers: `local` (command/env) or `remote` (url/headers/oauth); `{ "enabled": false }` disables one; `sampling` sets the client-sampling policy (`deny`/`ask`/`allow`, default `ask`) | | `tools` | Record of tool-id → boolean enable/disable | | `tool.invocation_style` | `json` (default) or `shell`; `tool.invocation_style_by_tool` for per-tool override | | `command` | Custom slash commands | diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/mcp-sampling.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/mcp-sampling.md new file mode 100644 index 000000000..08a986710 --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/mcp-sampling.md @@ -0,0 +1,310 @@ +# MCP Client-Side Sampling + +MiMoCode implements the MCP client `sampling` capability +([spec](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling)). +An MCP server can ask MiMoCode to run a model call on its behalf via +`sampling/createMessage`, so **the server never needs its own API key** — it +borrows the model connection the user already configured. + +## The motivating use case + +The MiMo Cut MCP server extracts a video's audio track to a 16 kHz mono WAV and +needs a transcript. Instead of shipping its own provider credentials, it sends +the WAV to MiMoCode as MCP `AudioContent` and asks for a completion: + +```json +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { "role": "user", "content": [ + { "type": "text", "text": "Transcribe this audio verbatim." }, + { "type": "audio", "data": "", "mimeType": "audio/wav" } + ]} + ], + "systemPrompt": "You are a verbatim transcription engine.", + "maxTokens": 2048, + "modelPreferences": { "hints": [{ "name": "mimo-v2.5" }] } + } +} +``` + +MiMoCode picks a configured, audio-capable model (preferring the hinted +`mimo-v2.5`), asks the user to approve, runs the call, and returns the model's +text unchanged. `MIMO_API_KEY` is never read or needed by the server. + +## Capability negotiation + +MiMoCode declares `capabilities.sampling = {}` during `initialize`. + +`sampling.tools` and `sampling.context` are deliberately **not** declared, +because they are not implemented. Per the spec a client must error when a server +sends `tools`/`toolChoice` without the `sampling.tools` declaration, and +MiMoCode does exactly that. `includeContext` defaults to `none`: MiMoCode never +ships your session history to an MCP server. + +## Model selection + +MCP publishes no model list and no modality discovery, so selection is driven by +the **Model Capability Registry** (`src/provider/capability-registry.ts`). The +order is **filter, then rank** — and never the other way round: + +1. **Derive** the required modalities from the actual content (text / image / + audio, plus each item's MIME type and decoded byte size). +2. **Filter** to models that are *both* capability-compatible *and* have + configured credentials. Compatibility ANDs two gates: + - the model's own declared input modalities (`capabilities.input.*`, from + models.dev metadata or your `/modalities` config), and + - whether the provider adapter behind it can actually serialize that media. +3. **Rank** the survivors using `modelPreferences.hints`, in the server's stated + order, exact model id first and substring second. +4. If no hint matches an eligible model, fall back to the ordinary model + selection strategy — but only if its answer is itself eligible. + +A hint is a *preference*, never an authorization: it can reorder eligible models +but can never make an ineligible one eligible. A server also cannot supply an +API key, base URL, or any other provider credential. + +When nothing is eligible, MiMoCode returns a **structured error** naming every +configured model and why it was rejected. It never drops the audio, never +downgrades it to a text description, and never sends it to a model that cannot +accept it. + +### Declared adapter support + +Audio support per adapter, and the evidence for each verdict (locked in by +`test/provider/capability-registry-wire.test.ts`, which drives the real adapter +and asserts the serialized request body): + +| Adapter | Audio | Accepted MIME | Evidence | +|---|---|---|---| +| `@ai-sdk/openai-compatible` | supported | `audio/wav`, `audio/mp3`, `audio/mpeg` | serializes these as `input_audio`; `audio/flac` and `audio/ogg` throw `functionality not supported` | +| `@ai-sdk/google` | supported | any `audio/*` | passes any audio MIME through as `inlineData` | +| `@ai-sdk/google-vertex` | supported | any `audio/*` | shares the `@ai-sdk/google` content conversion | +| `@ai-sdk/anthropic` | **unsupported** | — | an `audio/wav` part throws `'media type: audio/wav' functionality not supported`, while `image/png` serializes fine | +| `@ai-sdk/google-vertex/anthropic` | **unsupported** | — | shares the `@ai-sdk/anthropic` content conversion | +| `@ai-sdk/amazon-bedrock` | **unsupported** | — | excluded from every audio route in `src/session/tool-attachment.ts` (no direct wire probe) | +| anything else | **unknown** | — | no declaration; support is unproven, not disproven | + +`unknown` is a distinct third state on purpose. A provider whose audio support we +cannot substantiate is reported as unproven rather than guessed either way. Both +`unsupported` and `unknown` are ineligible (fail closed), but the error message +distinguishes them so you can tell "this cannot work" from "we do not know". + +## Permission model + +The default policy is **ask**. The approval prompt shows: + +- which MCP server asked, +- the model that will actually run, +- the content types present, and the size of any audio, +- previews of the system prompt and the user text prompt. + +Two independent controls, and a `deny` from **either** one refuses the request: + +- `permission.mcp_sampling` — standard permission rule, keyed by MCP server name + (`{ "*": "ask", "mimo-cut": "allow" }`). +- `mcp..sampling` — per-server policy: `deny` | `ask` | `allow` + (default `ask`). + +`deny` refuses before any prompt, model selection, or provider call. `allow` +skips the prompt but is **not** a bypass: request size caps, the request +timeout, and model capability checks all still apply — and it does **not** +override a `permission.mcp_sampling` deny. Because `allow` skips the approval +step entirely, the handler evaluates the permission ruleset itself rather than +relying on the prompt to surface a deny; the error names which control refused +(`data.deniedBy`). + +If a sampling request arrives while no turn is in flight for that server, the +`ask` policy fails closed rather than raising a prompt no UI is listening to. + +## Limits + +| Limit | Value | +|---|---| +| Media (image/audio) per item, decoded | 20 MiB | +| Text per item, and `systemPrompt` | 1 MiB | +| No output from the model (stall) | the provider's `chunkTimeout` — 8 min by default | + +That is the whole list of time bounds, and it is inherited rather than chosen. +There is **no total bound** on a sampling request, **no bound on the model call** +end to end, and **no bound on the approval wait**. Sampling sets one number of its +own — the 15 s liveness interval — and that one is a keepalive cadence, not a +deadline. + +### Why there is no total bound + +The main conversation path settles it. `src/session/llm.ts` puts no wall-clock +ceiling on a model call at all: its retry schedule is "intentionally NOT capped", +its own worst case is ~97 minutes, and it states the design in one line — +*"bounding per-attempt latency via `chunkTimeout` is the primary lever for +hang-time control"*. For a streaming call this repo's position is that **elapsed +total is not a health signal; silence is.** Sampling calls the same provider +through the same SDK, so a 2-minute total budget made it roughly 48× more +impatient than ordinary chat for no stated reason. It is gone, along with the +absolute ceiling that sat behind it. + +### Why there is no approval bound + +The same reason, measured in `src/permission/index.ts`: **an ordinary interactive +permission prompt has no timeout.** Only a *forwarded* ask and a forced-ask under +skip-permissions are bounded, and a sampling approval is neither. A TUI prompt +waits as long as the operator needs, so sampling giving up at 30 s was stricter +than anything comparable in the repo. The wait now ends when the operator answers, +when the peer cancels (its own request timeout is what produces that), or when the +MCP client closes. + +### The stall bound, and what it is not + +The model call **streams**, so "has this hung?" is answerable from evidence rather +than guessed: no output at all is a symptom, not a policy choice about how patient +MiMoCode is. It fails with `sampling stalled: the model produced no output` and +`data.phase: "stall"`. Its clock covers both the wait for the first chunk and every +gap between later chunks, and **every arriving chunk resets it** — a stream that +produces slowly for ten minutes never stalls, while one that goes quiet does. + +The error also carries `data.chunks` and `data.characters`, which separate the two +failures a server author would otherwise have to guess between: `chunks: 0` means +the provider never produced anything, and a non-zero count means it started and +then died. + +**Its value is the provider layer's `chunkTimeout`, not a sampling-specific +number.** Set `chunkTimeout` for a provider in `mimocode.json` and it governs +sampling exactly as it governs ordinary chat; leave it unset and both get the 8 +minute default. Sampling used to carry its own 45 s bound for the same question, +which disagreed with the provider layer by 10× — and in the wrong direction, since +`chunkTimeout` is tuned to tolerate a real cold-path first token that can be ~5 +minutes silent. A tighter number would have failed calls ordinary chat survives. + +What is deliberately **not** covered, stated rather than implied: a stream that +trickles just often enough never to look stalled and never finishes, and the +stretch before the model call (model selection, provider initialisation). Both are +unbounded here — and both are equally unbounded on the main chat path, which is the +basis for accepting them rather than inventing a number to cover them. + +Reaching this bound aborts the provider call as well as MiMoCode's wait +for it. That is not automatic: interrupting the Effect fiber does not cancel an +HTTP request already in flight inside it, so the handler hands the provider the +union of its own fiber's abort signal and the MCP request signal. Both a timeout +and a client teardown therefore reach the provider. + +### Keeping a server's own timer alive + +While the model call is in flight MiMoCode emits periodic +`notifications/progress` — every 15 s — so a server that asked for progress does +not abandon a request that is still being worked on. Without them a server on the +SDK's 60 s default gives up at 60 s while MiMoCode is still working, since +MiMoCode imposes no deadline of its own on the model call. + +What it does and does not claim: + +- **It reports observed output, but it is still not a fraction.** The model call + streams, so the notification says how many chunks and characters the provider has + actually produced — which is what lets a server tell "MiMoCode is alive" from + "the model is producing", a distinction a timer-driven tick cannot make. `total` + is still deliberately omitted, because streaming does not reveal how much output + is coming either, so no percentage can be read out of it. `progress` remains a + monotonic tick rather than the chunk count, since a chunk count does not advance + during exactly the quiet stretch a notification is most needed for. +- **It never carries the model's text.** Partial content is deliberately withheld: + a request that later stalls, times out or is cancelled returns **no** text at all, + so streaming a prefix over the progress channel would disclose in the failure case + precisely what the response contract says was never delivered. `onprogress` is + also how a peer asks to be told a request is alive — MCP has no partial-result + channel, and treating `message` as one would be MiMoCode deciding that on the + server's behalf. The running length is metadata and is disclosed; the text is not. +- **It is necessary, not sufficient.** Resetting the timer is the *requester's* + choice: the SDK's `resetTimeoutOnProgress` defaults to `false`, so a server + that did not pass it ignores these notifications entirely and still times out at + its own deadline. MiMoCode cannot make that decision for it. +- **It cannot extend MiMoCode's own bounds.** The notifications reset the peer's + timer only. The model call is still cut off at its own bound no matter how many + went out, so a hung provider cannot be kept alive by its own keepalive. + +Nothing is sent unless the server asked for it. The progress token belongs to the +requester — the SDK mints one only when its caller passed `onprogress`, and +MiMoCode reads it back off the request's `_meta`. No token means no notifications +at all. + +The media cap is a **client-side safety limit**, not a claim about any +provider's real limit. It exists so a buggy or hostile server cannot push an +unbounded payload through the sampling path. For scale: 30 s of 16 kHz mono +16-bit PCM WAV is about 0.92 MiB. + +## Concurrency, cancellation and cleanup + +A sampling request normally arrives **while MiMoCode is still waiting for that +same server's `tools/call` to return**. This does not deadlock, for two +independent reasons: + +1. The MCP SDK dispatches inbound requests from the transport's `onmessage` + without awaiting the handler, so serving a sampling request never blocks the + read loop that must later deliver the tool result. +2. MiMoCode runs the work on a fresh root Effect fiber, which shares no fiber, + lock, or scope with the fiber parked on `callTool`. + +Requests are served concurrently. A cancelled request unwinds through the +handler's abort signal, which is threaded into the approval wait and, composed +with the handler fiber's own signal, into the provider call. Closing or replacing +a client interrupts any sampling still in flight for it and aborts its provider +call, so an orphaned model call cannot outlive its transport. + +> **Upstream caveat.** In `@modelcontextprotocol/sdk` 1.27.1 a cancellation whose +> JSON-RPC `requestId` is `0` is silently dropped +> (`if (!notification.params.requestId) return` in `shared/protocol.js`), because +> `0` is falsy. The *first* server-initiated request on a connection is therefore +> uncancellable upstream; later ones cancel correctly. The stall bound is what keeps +> even that case from leaking, and it is the ONLY thing that does — which is why it +> is not gated on the server having asked for progress. Because it aborts the +> provider call it ends the model call rather than merely stopping us waiting for it. +> +> MiMoCode does not work around this, because it cannot: the id at risk belongs to +> the **server's** outgoing request counter, which only the server can advance. +> Spending an id from the client side — a ping at connection setup, say — advances +> the client's own counter and leaves the server's at `0`, so the server's +> cancellations still do not land. Measured, together with the failing and working +> cases, in `test/mcp/sampling-e2e.test.ts`; those tests fail if an SDK upgrade +> changes this behaviour. +> +> The residual, stated plainly: **when a server abandons the first sampling request +> it issues on a connection, MiMoCode does not learn of it, so that request keeps a +> model call running — and a paid one — until one of MiMoCode's own bounds aborts +> it — which happens once the provider goes quiet for its `chunkTimeout`. If the +> provider instead keeps trickling output indefinitely, nothing here stops it; that +> is the same exposure ordinary chat carries, and it is accepted on the same terms.** +> The bound caps the waste; it does not avoid it. + +## Security boundaries + +- API keys, `Authorization` headers, and provider configuration never appear in a + sampling response, in logs, or in any error payload. +- Logs record only the server, model, content types, sizes, duration, and result + status. Never the audio bytes, and never a full prompt — prompt previews are + whitespace-collapsed and truncated. +- The model's text is returned **verbatim**. MiMoCode does not summarise or + rewrite it. +- Session context is never forwarded to an MCP server. + +## Result and error mapping + +Success returns the spec's `CreateMessageResult`: `role`, `content`, `model` (the +model actually used, as `provider/model`), and `stopReason` +(`endTurn` / `stopSequence` / `maxTokens`). + +| Situation | JSON-RPC code | +|---|---| +| Malformed params, bad base64/MIME, oversize, no compatible model | `-32602` InvalidParams | +| Policy `deny`, user declined, no session for an `ask` | `-1` | +| Cancelled or timed out | `-32001` | +| Provider failure, model init failure | `-32603` InternalError | + +`-32001` cannot be read alone: it is the SDK's own `RequestTimeout`, which the +SDK's request timeout raises too, with a `data.timeout` our bound also sets. Only +our errors carry `data.server`, so that field is what says whose deadline fired. + +Once it is ours, `data.phase` has exactly one possible value — `"stall"` — because +that is now the only deadline MiMoCode imposes; the `"approval"`, `"model"` and +`"total"` phases went away with the bounds that produced them. A `"stall"` carries +`data.chunks` and `data.characters`, so a server can tell a provider that never +produced anything from one that stopped part way. A cancellation carries +`data.server` with no `phase`, because nothing expired. diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/permissions.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/permissions.md index 1a81ff9cb..6d0f17eb4 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/permissions.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/permissions.md @@ -14,7 +14,7 @@ Every rule resolves to one of three actions: ## Two shapes -A permission rule is **either** a single action string, **or** a glob-keyed map of action strings (for tools whose argument is a path or command): +A permission rule is **either** a single action string, **or** a glob-keyed map of action strings (for tools whose argument is a path, a command, or another name worth matching on): ```jsonc { @@ -36,12 +36,24 @@ A bare string at the top level (`"permission": "allow"`) becomes `{ "*": action ## Configurable tools -Path/command-keyed (accept the glob-map form): `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `actor`, `external_directory`, `lsp`, `skill`. +Glob-keyed (accept the glob-map form): `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `actor`, `external_directory`, `lsp`, `skill`, `mcp_sampling`. The key is the tool's path or command argument, except for `mcp_sampling`, where it is the MCP server name. Simple action-only: `question`, `webfetch`, `websearch`, `codesearch`, `doom_loop`. `doom_loop` is the safety gate raised when repeated identical tool calls look like an infinite loop. Keep it at `ask` (the default) unless the surrounding automation has another reliable stop condition. +`mcp_sampling` gates MCP **client-side sampling**: an MCP server asking MiMoCode to run a model call on its behalf (`sampling/createMessage`), so the server needs no API key of its own. The glob argument is the MCP server name, so you can allow one server and keep the rest at `ask`: + +```jsonc +{ + "permission": { + "mcp_sampling": { "*": "ask", "mimo-cut": "allow" } + } +} +``` + +The prompt shows which server asked, the model that will run, the content types and audio size, and previews of the system and user prompts. A per-server `mcp..sampling` value of `deny` refuses before any prompt or model work; `allow` skips the prompt but still enforces size caps, the request timeout, and model capability checks. A `deny` from either control wins: `mcp..sampling: "allow"` does **not** override `permission.mcp_sampling` denying that server. See @mcp-sampling.md. + Unknown keys fall through to a catch-all record, so future/custom tools can be named too. ## Common recipes diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 8a2dd373c..97c493201 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -16,6 +16,7 @@ interface MockClientState { resources: Array<{ name: string; uri: string; description?: string }> closed: boolean notificationHandlers: Map any> + requestHandlers: Map any> serverCapabilities: Record toolCalls: Array> toolCallSignals: Array @@ -56,6 +57,7 @@ function getOrCreateClientState(name?: string): MockClientState { resources: [], closed: false, notificationHandlers: new Map(), + requestHandlers: new Map(), serverCapabilities: {}, toolCalls: [], toolCallSignals: [], @@ -154,6 +156,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ this._state?.notificationHandlers.set(schema, handler) } + // Production registers a `sampling/createMessage` request handler on every + // client (see MCP.CLIENT_OPTIONS + McpSampling.serve), so the double has to + // offer the same method or every connect path throws. + setRequestHandler(schema: unknown, handler: (...args: any[]) => any) { + this._state?.requestHandlers.set(schema, handler) + } + getServerCapabilities() { return this._state?.serverCapabilities } @@ -317,9 +326,14 @@ test( command: ["echo", "test"], }) + // Kept as an EXACT toEqual so any unintended capability addition still + // fails here. `sampling: {}` is declared because production registers a + // sampling/createMessage request handler and the SDK refuses that + // registration otherwise; sampling.tools/context stay undeclared. expect(clientOptions).toEqual([ { capabilities: { + sampling: {}, experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 }, }, diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 736bc4679..1ec711e7a 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -90,6 +90,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ setNotificationHandler() {} + // Production registers a sampling/createMessage request handler on every + // client, so the double must offer this or connect throws. + setRequestHandler() {} + async listTools() { return { tools: [{ name: "test_tool", inputSchema: { type: "object", properties: {} } }] } } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 0d7a09a4c..c8383c630 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -86,6 +86,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ async connect(transport: { start: () => Promise }) { await transport.start() } + + setNotificationHandler() {} + + // Production registers a sampling/createMessage request handler on every + // client, so the double must offer this or connect throws. + setRequestHandler() {} }, })) diff --git a/packages/opencode/test/mcp/sampling-e2e.test.ts b/packages/opencode/test/mcp/sampling-e2e.test.ts new file mode 100644 index 000000000..c0163405a --- /dev/null +++ b/packages/opencode/test/mcp/sampling-e2e.test.ts @@ -0,0 +1,2061 @@ +import { test, expect, describe, afterEach } from "bun:test" +import path from "path" +import { Effect } from "effect" +import type { Client as ClientType } from "@modelcontextprotocol/sdk/client/index.js" +import type { McpServer as McpServerType } from "@modelcontextprotocol/sdk/server/mcp.js" +import { z } from "zod/v4" + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { AppRuntime } from "../../src/effect/app-runtime" +import { EffectBridge } from "../../src/effect" +import { McpSampling } from "../../src/mcp/sampling" +import { DEFAULT_CHUNK_TIMEOUT } from "../../src/provider/provider" +import { MCP } from "../../src/mcp/index" +import { Permission } from "../../src/permission" +import type { SessionID } from "../../src/session/schema" +import { wav } from "./wav-fixture" + +/** + * END-TO-END proof of MCP client-side sampling over a REAL bidirectional + * JSON-RPC link. + * + * A real SDK `McpServer` exposes `transcribe_audio_fixture`. While that tool is + * executing — i.e. while our side is still awaiting the `tools/call` response — + * the server issues `sampling/createMessage` back at us carrying a 16 kHz mono + * WAV. Our production handler (`McpSampling.serve`, the same function + * `src/mcp/index.ts` wires up) selects a model, runs it, and answers. The server + * then finishes its tool call with the transcript it received. + * + * The provider is mocked at the HTTP boundary only, so the audio genuinely passes + * through the real `@ai-sdk/openai-compatible` conversion layer and we can assert + * on the bytes that reached the wire. No real model and no real API key are used. + * + * THE SDK IS LOADED FROM ITS CJS BUILD ON PURPOSE. Sibling files + * (lifecycle.test.ts, oauth-*.test.ts) call + * `mock.module("@modelcontextprotocol/sdk/client/index.js")` at module scope. + * Bun's module mocks are process-wide, survive across test files, cannot be + * bypassed by importing the same module through an absolute path or a file URL, + * and CI shards by file — so which mocks are live when this file runs is not + * something a file name can control. The package's `dist/cjs` tree is a different + * set of physical files and therefore a different set of module records, so it is + * immune. Every SDK value below comes from that single realm so no cross-realm + * classes are mixed. The `harness integrity` test fails loudly if this ever stops + * yielding the genuine `Client`. + */ + +/** The real SDK, loaded from `dist/cjs` so no `mock.module` can intercept it. */ +const sdk = await (async () => { + const esmEntry = Bun.resolveSync("@modelcontextprotocol/sdk/client/index.js", import.meta.dir) + const cjsClient = esmEntry.replace(`${path.sep}esm${path.sep}`, `${path.sep}cjs${path.sep}`) + const cjsDir = path.dirname(path.dirname(cjsClient)) + const load = async (relative: string) => { + const mod: any = await import(path.join(cjsDir, relative)) + return mod.Client || mod.McpServer || mod.InMemoryTransport || mod.CallToolResultSchema ? mod : mod.default + } + const [client, server, inMemory, types] = await Promise.all([ + load("client/index.js"), + load("server/mcp.js"), + load("inMemory.js"), + load("types.js"), + ]) + return { + Client: client.Client as unknown as typeof ClientType, + McpServer: server.McpServer as unknown as typeof McpServerType, + InMemoryTransport: inMemory.InMemoryTransport, + CallToolResultSchema: types.CallToolResultSchema, + CreateMessageRequestSchema: types.CreateMessageRequestSchema, + CreateMessageResultSchema: types.CreateMessageResultSchema, + } +})() + +const { + Client, + McpServer, + InMemoryTransport, + CallToolResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, +} = sdk + +const TRANSCRIPT = "the quick brown fox jumps over the lazy dog" +const SESSION = "ses_sampling_e2e" as SessionID + +// A single self-contained config provider. `npm` + `models` + `apiKey` are all +// declared so the provider loads deterministically without env-key autoload, +// mirroring test/provider/model-groups.test.ts. +const PROVIDER_ID = "samplingfixture" + +const PROVIDERS = { + [PROVIDER_ID]: { + name: "Sampling Fixture", + npm: "@ai-sdk/openai-compatible", + env: [], + api: "https://example.invalid/v1", + options: { apiKey: "test-key", baseURL: "https://example.invalid/v1" }, + models: { + "mimo-v2.5": { + name: "MiMo v2.5", + tool_call: true, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + limit: { context: 128_000, output: 8_000 }, + }, + "mimo-text-only": { + name: "MiMo Text Only", + tool_call: true, + modalities: { input: ["text"], output: ["text"] }, + limit: { context: 128_000, output: 8_000 }, + }, + }, + }, +} + +interface Wire { + bodies: Array + restore: () => void +} + +/** + * One `chat.completion.chunk` envelope. `id`/`model` are fixed so the deltas below + * differ only in their content. + */ +const CHUNK = { id: "chatcmpl-1", object: "chat.completion.chunk", created: 1, model: "mimo-v2.5" } + +/** + * Split text into deltas that CONCATENATE BACK TO IT EXACTLY — whitespace is kept + * with the word it follows — because the round-trip assertions compare the + * assembled result against the original string byte for byte. + */ +function splitDeltas(text: string): ReadonlyArray { + return text.match(/\S+\s*/g) ?? [text] +} + +function sseEvent(payload: object) { + return `data: ${JSON.stringify(payload)}\n\n` +} + +function deltaEvent(content: string) { + return sseEvent({ ...CHUNK, choices: [{ index: 0, delta: { content }, finish_reason: null }] }) +} + +function finishEvents(finishReason = "stop") { + return ( + sseEvent({ + ...CHUNK, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + }) + "data: [DONE]\n\n" + ) +} + +/** + * Replace global fetch with an OpenAI-compatible chat-completions stub. + * + * SERVER-SENT EVENTS, because sampling calls `streamText`: the adapter requests + * `stream: true` and parses `text/event-stream`. This fixture used to answer with a + * single non-streaming JSON completion, and the conversion is not cosmetic — a JSON + * body does NOT fail loudly against a streaming adapter, it yields a stream + * carrying no text deltas at all, so every transcript assertion would have started + * comparing against `""`. The assertions themselves are untouched: same text, same + * `stopReason`, same audio bytes on the wire. + */ +function stubProvider(text: string): Wire { + const original = globalThis.fetch + const bodies: Array = [] + globalThis.fetch = (async (url: any, init: any) => { + const href = typeof url === "string" ? url : (url?.url ?? String(url)) + if (!href.includes("example.invalid")) return original(url, init) + bodies.push(JSON.parse(init.body as string)) + return new Response(splitDeltas(text).map(deltaEvent).join("") + finishEvents(), { + headers: { "content-type": "text/event-stream" }, + }) + }) as typeof fetch + return { bodies, restore: () => (globalThis.fetch = original) } +} + +/** + * Like `stubProvider`, but the chat-completions call NEVER answers — the shape of + * a provider that has accepted the request and gone quiet. `release()` settles the + * abandoned calls after the assertions so no promise is left dangling. + * + * `signals` captures the `AbortSignal` each captive call was issued with, which is + * how a test can assert that abandoning a request actually ABORTED the provider + * call rather than merely stopping our own waiting for it. + */ +function stubProviderHang(): Wire & { signals: Array; release: () => void } { + const original = globalThis.fetch + const bodies: Array = [] + const signals: Array = [] + const pending: Array<(response: Response) => void> = [] + globalThis.fetch = (async (url: any, init: any) => { + const href = typeof url === "string" ? url : (url?.url ?? String(url)) + if (!href.includes("example.invalid")) return original(url, init) + bodies.push(JSON.parse(init.body as string)) + signals.push(init?.signal as AbortSignal | undefined) + return new Promise((resolve) => pending.push(resolve)) + }) as typeof fetch + return { + bodies, + signals, + restore: () => (globalThis.fetch = original), + release: () => { + for (const resolve of pending.splice(0)) { + resolve( + new Response(deltaEvent("too late") + finishEvents(), { + headers: { "content-type": "text/event-stream" }, + }), + ) + } + }, + } +} + +/** + * A provider that streams deltas SLOWLY, and can stop mid-stream and never finish. + * + * `gapMs` is the pause before each delta, i.e. exactly the inter-chunk gap the + * stall detector measures — which is what makes both halves of that detector + * testable: gaps under the bound must NOT stall, and going quiet must. With + * `thenSilent` the response body stays open forever after the last delta, which is + * the shape of a provider that answered, started producing, and died — distinct + * from `stubProviderHang`, where nothing ever arrives at all. + */ +function stubProviderTrickle(input: { + deltas: ReadonlyArray + gapMs: number + thenSilent?: boolean +}): Wire & { signals: Array; release: () => void } { + const original = globalThis.fetch + const bodies: Array = [] + const signals: Array = [] + const closers: Array<() => void> = [] + let abandoned = false + globalThis.fetch = (async (url: any, init: any) => { + const href = typeof url === "string" ? url : (url?.url ?? String(url)) + if (!href.includes("example.invalid")) return original(url, init) + bodies.push(JSON.parse(init.body as string)) + signals.push(init?.signal as AbortSignal | undefined) + const encoder = new TextEncoder() + const body = new ReadableStream({ + async start(controller) { + let open = true + const close = () => { + if (!open) return + open = false + try { + controller.close() + } catch { + // Already closed or cancelled by the consumer; nothing to do. + } + } + closers.push(close) + for (const delta of input.deltas) { + await new Promise((resolve) => setTimeout(resolve, input.gapMs)) + if (!open || abandoned) return + controller.enqueue(encoder.encode(deltaEvent(delta))) + } + // Hold the connection open and silent: the stall detector, not the end of + // the stream, has to be what ends this request. + if (input.thenSilent) return + if (!open) return + controller.enqueue(encoder.encode(finishEvents())) + close() + }, + cancel() { + abandoned = true + }, + }) + return new Response(body, { headers: { "content-type": "text/event-stream" } }) + }) as typeof fetch + return { + bodies, + signals, + restore: () => (globalThis.fetch = original), + release: () => { + abandoned = true + for (const close of closers.splice(0)) close() + }, + } +} + +let wire: Wire | undefined + +afterEach(() => { + wire?.restore() + wire = undefined +}) + +interface Harness { + client: ClientType + server: McpServerType + /** Sampling requests the server issued, and what it got back. */ + samplingOutcomes: Array<{ ok: boolean; detail: unknown }> + /** True while the fixture tool is mid-execution. */ + toolActive: () => boolean + /** + * `notifications/progress` messages that actually reached the server. Recorded + * off the transport, not off our own bookkeeping, so a test cannot pass because + * we counted an intention rather than a delivered message. + */ + progressNotifications: Array +} + +/** + * A real client/server pair over InMemoryTransport, with our production sampling + * handler registered on the client exactly as `src/mcp/index.ts` registers it. + */ +async function harness(input: { + audio?: { data: string; mimeType: string } + hints?: Array<{ name: string }> + text?: string + /** Abort the sampling request once a permission prompt is pending. */ + cancelAfterAsk?: boolean + /** + * Passed straight through to `server.server.request`. Supplying it is the ONLY + * way a progress token comes into existence: the SDK mints one solely when its + * caller asked for progress (`if (options?.onprogress) { ... progressToken: + * messageId }`, shared/protocol.js). Omitting it models a server that never + * opted in — and then our side must send nothing at all. + */ + onprogress?: (progress: any) => void + /** The server's own per-request timeout; left at the SDK's 60 s when absent. */ + requestTimeout?: number +}): Promise { + const server = new McpServer({ name: "fixture", version: "1.0.0" }) + const samplingOutcomes: Array<{ ok: boolean; detail: unknown }> = [] + let active = false + + server.registerTool( + "transcribe_audio_fixture", + { + description: "Transcribes bundled fixture audio by asking the client to sample a model.", + inputSchema: { note: z.string().optional() }, + }, + async () => { + active = true + try { + // Server -> client REQUEST issued while the client is still awaiting this + // tool's own response. If either direction blocked the other, this await + // would never settle. + const content = input.audio + ? [ + { type: "text" as const, text: "Transcribe this audio verbatim." }, + { type: "audio" as const, data: input.audio.data, mimeType: input.audio.mimeType }, + ] + : [{ type: "text" as const, text: input.text ?? "say hello" }] + const controller = new AbortController() + if (input.cancelAfterAsk) { + // Burn JSON-RPC request id 0 first. The SDK drops a cancellation whose + // `requestId` is 0 (`if (!notification.params.requestId) return` in + // shared/protocol.js), so the FIRST server-initiated request of a + // connection is uncancellable upstream. Cancelling a later request + // exercises the path our handler actually has to survive. + await server.server.ping() + // Cancel as soon as the client has raised its approval prompt, i.e. + // while our handler is genuinely parked mid-request. + void waitForAsk().then( + () => controller.abort(new Error("server cancelled sampling")), + () => controller.abort(new Error("server cancelled sampling")), + ) + } + const result = await server.server.request( + { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content }], + systemPrompt: "You are a verbatim transcription engine.", + maxTokens: 2048, + ...(input.hints ? { modelPreferences: { hints: input.hints } } : {}), + }, + }, + CreateMessageResultSchema, + { + signal: controller.signal, + ...(input.onprogress ? { onprogress: input.onprogress } : {}), + ...(input.requestTimeout !== undefined ? { timeout: input.requestTimeout } : {}), + }, + ) + samplingOutcomes.push({ ok: true, detail: result }) + return { content: [{ type: "text", text: (result.content as { text: string }).text }] } + } catch (error: any) { + samplingOutcomes.push({ ok: false, detail: { code: error?.code, message: error?.message, data: error?.data } }) + return { content: [{ type: "text", text: `SAMPLING_ERROR ${error?.code}` }], isError: true } + } finally { + active = false + } + }, + ) + + // PRODUCTION's own capability object, not a copy — so a regression that drops + // `sampling` from src/mcp/index.ts fails these tests instead of passing against + // a duplicated literal. + const client = new Client({ name: "mimocode", version: "test" }, MCP.CLIENT_OPTIONS) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.server.connect(serverTransport)]) + // Tap the SERVER's inbound transport after connect. `Protocol.connect` chains + // whatever `onmessage` it found, so wrapping the one it installed keeps the + // SDK's own dispatch intact while letting us see the raw wire messages. + const progressNotifications: Array = [] + const installed = serverTransport.onmessage?.bind(serverTransport) + serverTransport.onmessage = (message: any, extra: any) => { + if (message?.method === "notifications/progress") progressNotifications.push(message) + installed?.(message, extra) + } + return { client, server, samplingOutcomes, toolActive: () => active, progressNotifications } +} + +/** Register production sampling handling on a client inside a live Instance. */ +function wireSampling( + client: ClientType, + serverName = "fixture", + livenessIntervalMs?: number, + chunkTimeoutMs?: number, +) { + return AppRuntime.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + McpSampling.setActiveSession(client, SESSION) + McpSampling.serve(serverName, client as never, bridge, livenessIntervalMs, chunkTimeoutMs) + }), + ) +} + +/** Poll the permission service until a sampling prompt is pending. */ +async function waitForAsk() { + for (let attempt = 0; attempt < 200; attempt++) { + const pending = await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }), + ) + const match = pending.find((item) => item.permission === "mcp_sampling") + if (match) return match + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error("no mcp_sampling permission request was raised") +} + +/** Wait, bounded, for a client's sampling fibers to retire. */ +async function drainInFlight(client: object) { + for (let attempt = 0; attempt < 200; attempt++) { + if (McpSampling.inFlightCount(client) === 0) return + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error(`sampling fibers never drained (${McpSampling.inFlightCount(client)} left)`) +} + +function config(extra?: Record) { + return { + $schema: "https://opencode.ai/config.json", + provider: PROVIDERS, + // `enabled_providers` is an ALLOWLIST: without it this machine's real + // provider credentials autoload and sampling would pick a live model. + enabled_providers: [PROVIDER_ID], + model: `${PROVIDER_ID}/mimo-v2.5`, + ...extra, + } +} + +async function withInstance(cfg: object, fn: () => Promise) { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "mimocode.json"), JSON.stringify(cfg)) + }, + }) + await Instance.provide({ directory: tmp.path, fn }) +} + +describe("harness integrity", () => { + test("the SDK Client under test is the REAL one, not a sibling file's mock", () => { + // Real Client extends Protocol and carries these; every test double in + // test/mcp/ carries neither. If a module mock ever wins the load-order race, + // this fails instead of letting the whole E2E pass against a stub. + expect(typeof (Client.prototype as any).assertRequestHandlerCapability).toBe("function") + expect(typeof (Client.prototype as any).ping).toBe("function") + expect(typeof (Client.prototype as any).callTool).toBe("function") + }) +}) + +describe("MCP client-side sampling, end to end", () => { + test("declares the sampling capability during initialize", async () => { + const h = await harness({ text: "hi" }) + // What the SERVER observed on the wire, not what we passed in. + expect(h.server.server.getClientCapabilities()).toMatchObject({ sampling: {} }) + // Not-yet-implemented sub-capabilities must stay undeclared. + const capabilities = h.server.server.getClientCapabilities() as Record + expect(capabilities.sampling.tools).toBeUndefined() + expect(capabilities.sampling.context).toBeUndefined() + await h.client.close() + }) + + test("a 30s 16kHz mono WAV round-trips through a nested sampling request without deadlock", async () => { + wire = stubProvider(TRANSCRIPT) + const buffer = wav(30) + const data = buffer.toString("base64") + + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const h = await harness({ audio: { data, mimeType: "audio/wav" }, hints: [{ name: "mimo-v2.5" }] }) + await wireSampling(h.client) + + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + + // 1. The tool completed, so neither direction blocked the other. This is + // the self-lock gate: the fixture tool only returns AFTER its own + // sampling request resolved, so a sampling path that waited on the + // outstanding tool call would circular-wait and time out here. + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ text: string }>)[0].text).toBe(TRANSCRIPT) + + // 2. The server's own sampling call succeeded, with the model we selected. + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.model).toBe(`${PROVIDER_ID}/mimo-v2.5`) + expect(detail.role).toBe("assistant") + // Verbatim: exactly the provider's text, not a summary. + expect(detail.content).toEqual({ type: "text", text: TRANSCRIPT }) + expect(detail.stopReason).toBe("endTurn") + + // 3. The WAV really reached the provider as audio, not as text. + expect(wire!.bodies).toHaveLength(1) + const parts = wire!.bodies[0].messages.at(-1).content + expect(parts).toEqual([ + { type: "text", text: "Transcribe this audio verbatim." }, + { type: "input_audio", input_audio: { data, format: "wav" } }, + ]) + // 4. No credential rode along in the JSON-RPC payload the server saw. + expect(JSON.stringify(detail)).not.toContain("test-key") + expect(JSON.stringify(detail)).not.toContain("example.invalid") + + await h.client.close() + }) + }, 60_000) + + // Pins the PRECONDITION for the deadlock coverage above, and is not itself a + // deadlock proof: the SDK dispatches inbound requests from `onmessage` without + // awaiting them (shared/protocol.js `_onrequest`), so "the request arrived + // during the tool call" is guaranteed by the SDK and would pass for free. The + // real self-lock coverage is the round-trip test above, which drives sampling + // through the actual model-acquisition path to a returned CreateMessageResult + // with the tool call still outstanding; making that path wait on the + // outstanding tool call (a turn lock / serial prompt queue) times it out. + test("the sampling request is served WHILE the tool call is still in flight", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + let activeDuringSampling: boolean | undefined + const h = await harness({ text: "hi" }) + await AppRuntime.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + McpSampling.setActiveSession(h.client, SESSION) + // Wrap the production handler so we can observe tool-call state at the + // moment the inbound request is dispatched. The handler itself is + // production code; only the observation is added. + const spy = { + setRequestHandler: (schema: never, handler: never) => { + h.client.setRequestHandler(schema, (async (request: any, extra: any) => { + activeDuringSampling = h.toolActive() + return (handler as any)(request, extra) + }) as never) + }, + } + McpSampling.serve("fixture", spy as never, bridge) + }), + ) + + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBeFalsy() + // The proof: the fixture tool had NOT returned when we began sampling. + expect(activeDuringSampling).toBe(true) + await h.client.close() + }) + }, 60_000) + + test("audio is refused when only text-capable models are configured, and never downgraded", async () => { + wire = stubProvider(TRANSCRIPT) + const data = wav(1).toString("base64") + const textOnly = { + ...config(), + model: `${PROVIDER_ID}/mimo-text-only`, + provider: { + [PROVIDER_ID]: { + ...PROVIDERS[PROVIDER_ID], + models: { "mimo-text-only": PROVIDERS[PROVIDER_ID].models["mimo-text-only"] }, + }, + }, + mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } }, + } + await withInstance(textOnly, async () => { + const h = await harness({ audio: { data, mimeType: "audio/wav" } }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes[0].ok).toBe(false) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-32602) + expect(detail.message).toMatch(/no configured model can accept/) + // The structured error names the model and the reason. + expect(detail.data.rejected).toEqual([ + { model: `${PROVIDER_ID}/mimo-text-only`, reason: "does not accept audio input" }, + ]) + expect(detail.data.required).toContainEqual({ modality: "audio", mimeType: "audio/wav", bytes: 32044 }) + // Nothing was sent to any provider: no silent downgrade to a text call. + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }) + }, 60_000) + + /** + * The OTHER fail-closed branch: the adapter's audio support is `unknown`, not + * known-absent. The test above exercises `unsupported`; nothing exercised + * `unknown` past the registry's own leaf function. + * + * `@ai-sdk/mistral` is bundled (so this stays offline and installs nothing) and + * carries no entry in the registry's adapter table — exactly the shape of a + * provider added after that table was written. The model itself declares audio + * input, so the MODEL gate passes and only the adapter verdict can refuse. + */ + test("audio is refused when the only audio-declaring model's adapter support is UNKNOWN", async () => { + wire = stubProvider(TRANSCRIPT) + const data = wav(1).toString("base64") + const UNDECLARED_ID = "undeclaredfixture" + await withInstance( + config({ + provider: { + [UNDECLARED_ID]: { + name: "Undeclared Adapter Fixture", + npm: "@ai-sdk/mistral", + env: [], + api: "https://example.invalid/v1", + options: { apiKey: "test-key", baseURL: "https://example.invalid/v1" }, + models: { + "sonic-1": { + name: "Sonic 1", + tool_call: true, + modalities: { input: ["text", "audio"], output: ["text"] }, + limit: { context: 128_000, output: 8_000 }, + }, + }, + }, + }, + enabled_providers: [UNDECLARED_ID], + model: `${UNDECLARED_ID}/sonic-1`, + mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } }, + }), + async () => { + const h = await harness({ audio: { data, mimeType: "audio/wav" } }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes[0].ok).toBe(false) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-32602) + expect(detail.message).toMatch(/no configured model can accept/) + // "has no declared" — NOT "does not accept". An operator can tell an + // unproven adapter from a disproven one. + expect(detail.data.rejected).toEqual([ + { model: `${UNDECLARED_ID}/sonic-1`, reason: "has no declared audio support" }, + ]) + // The unproven adapter was not "tried anyway": no request was built, and + // the bundled Mistral adapter was never even loaded. + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }, + ) + }, 60_000) + + test("policy deny refuses before any model or provider work", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "deny" } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-1) + expect(detail.message).toMatch(/denied/) + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }) + }, 60_000) + + /** + * This test USED to drive its rejection with `permission.mcp_sampling: "deny"` + * and assert the "user declined" message. That conflated two different refusals, + * and a ruleset deny is now refused up front (next test) precisely so that + * `mcp..sampling: "allow"` cannot bury it — so that config no longer + * reaches a prompt and no longer produces "declined". The genuine human + * rejection it never covered is now its own test, below. + */ + test("policy ask requires approval: no provider call happens until it is answered", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const pending = h.client + .callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { timeout: 30_000 }) + .catch(() => undefined) + const request = await waitForAsk() + expect(request.permission).toBe("mcp_sampling") + expect(request.patterns).toEqual(["fixture"]) + // Gated BEHIND the approval, not merely reported after it. + expect(wire!.bodies).toHaveLength(0) + expect(McpSampling.inFlightCount(h.client)).toBe(1) + await AppRuntime.runPromise(McpSampling.cancelAll(h.client)) + await h.client.close() + await pending + }) + }, 60_000) + + /** + * THE GENUINE HUMAN REJECTION — `permission.reply({ reply: "reject" })` against a + * prompt that was actually raised. Distinct from every "deny" test around it: + * those refuse before a prompt exists, so they never exercise the approval + * Deferred at all, and the `-1` declined error in `handle` was therefore + * unreachable in practice. + * + * It was unreachable for a reason, which is what this test pins. `Permission.ask` + * races the approval Deferred against the caller's `abortSignal`, and sampling + * always passes `extra.signal`. `Effect.race` resolves with the first *success* + * and treats a failure as "not a winner"; a rejection FAILS the Deferred and the + * abort side never settles on its own, so the ask parked forever. `raceFirst` + * (first side to *complete*, success or failure) is the fix. + * + * THERE IS NO LONGER A BOUND TO INJECT AS A SAFETY NET, and that changes what + * catches a regression rather than whether one is caught. The total bound this + * test used to set to 8 s is gone (see the deadlines block), so a regression that + * re-parks the ask now hangs until Bun's own 60 s test timeout instead of being + * reaped at 8 s with "sampling timed out". Slower, still a FAILURE and never a + * pass — and the promptness assertion below is unchanged, so the property proven + * is the same one. + */ + test("a human rejection answers the server with the declined error and drains the fiber", async () => { + // The ceiling the answer must beat. Previously expressed as half the injected + // bound; kept at the same absolute value now that no bound is injected. + const PROMPT_CEILING = 4_000 + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const pending = h.client + .callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { timeout: 30_000 }) + .catch(() => undefined) + const request = await waitForAsk() + expect(McpSampling.inFlightCount(h.client)).toBe(1) + + const rejectedAt = Date.now() + await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + yield* permission.reply({ requestID: request.id, reply: "reject" }) + }), + ) + + // THE SYMPTOM, not the bookkeeping: the server's own sampling request must + // come back answered. Before the fix it came back with nothing at all. + for (let attempt = 0; attempt < 400 && h.samplingOutcomes.length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + const detail = h.samplingOutcomes[0].detail as any + // `-1` + "declined" + `data.server`, all three: our request-timeout error + // and the SDK's own both use -32001 and both carry `data.timeout`, so code + // alone cannot tell a declined answer from a reaped one. + expect(detail.code).toBe(-1) + expect(String(detail.message)).toMatch(/declined/) + expect(detail.data).toMatchObject({ server: "fixture" }) + // Answered by the rejection, not by anything expiring. + expect(Date.now() - rejectedAt).toBeLessThan(PROMPT_CEILING) + // A refusal never reaches a model. + expect(wire!.bodies).toHaveLength(0) + // Polled, not synchronous: the server's request settles when our JSON-RPC + // error is written, which is not ordered against `serve`'s finally block. + await drainInFlight(h.client) + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + await pending + }) + }, 60_000) + + /** + * THE MIRROR CASE, so the fix cannot buy the rejection path at the cost of + * promptness on a real abort. A server-issued cancellation aborts `extra.signal` + * while the prompt is still pending; the ask must abandon it immediately, not sit + * there until an outer bound reaps it. + * + * This one passes both before and after the fix and is a REGRESSION GUARD, not a + * reproducer — stated plainly because the two are not the same evidence. Under + * `race` an abort happened to work only because it fails BOTH sides (the callback + * resumes with a failure *and* it fails the Deferred), and `race` does surface a + * failure once every side has failed. The rejection path failed only one side, + * which is the whole asymmetry. A fix that bought the rejection path by dropping + * the abort composition would still satisfy the existing cancellation test, which + * polls for the outcome and so tolerates arriving at the bound; this does not. + * + * The promptness ceiling is 5 s and there is now NO outer bound at all — the + * approval wait is unbounded by design, so the only alternative to the abort path + * is hanging until Bun's 60 s test timeout. That makes this assertion strictly + * harder to satisfy by accident than when a 20 s bound stood behind it. + */ + test("an abort while the prompt is pending abandons it promptly, not by expiring", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + const h = await harness({ text: "hi", cancelAfterAsk: true }) + await wireSampling(h.client) + const startedAt = Date.now() + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + // The abort answered it, and nothing else could have: the approval wait has no + // bound, so a regression that lost the abort composition would hang instead. + expect(Date.now() - startedAt).toBeLessThan(5_000) + expect(wire!.bodies).toHaveLength(0) + await drainInFlight(h.client) + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + }) + }, 60_000) + + test("a permission ruleset deny under policy ask refuses without ever prompting", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance( + config({ + mcp: { fixture: { type: "local", command: ["true"] } }, + permission: { mcp_sampling: "deny" }, + }), + async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-1) + expect(detail.message).toMatch(/denied/) + // A deny is a refusal, not a human declining a prompt that was raised. + expect(detail.data).toMatchObject({ server: "fixture", deniedBy: "permission.mcp_sampling" }) + const prompts = await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }), + ) + expect(prompts.filter((item) => item.permission === "mcp_sampling")).toHaveLength(0) + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }, + ) + }, 60_000) + + test("policy ask proceeds when the user approves", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance( + config({ + mcp: { fixture: { type: "local", command: ["true"] } }, + permission: { mcp_sampling: "allow" }, + }), + async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBeFalsy() + expect(h.samplingOutcomes[0].ok).toBe(true) + await h.client.close() + }, + ) + }, 60_000) + + test("concurrent sampling requests all complete", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const results = await Promise.all( + [0, 1, 2, 3].map(() => + h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }), + ), + ) + for (const result of results) expect(result.isError).toBeFalsy() + expect(h.samplingOutcomes).toHaveLength(4) + expect(h.samplingOutcomes.every((item) => item.ok)).toBe(true) + // Every fiber was retired from the in-flight set. + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + }) + }, 60_000) + + test("an oversize audio payload is refused with a structured error", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + // 4 base64 chars per 3 bytes; ask for one byte past the cap. + const bytes = 20 * 1024 * 1024 + 3 + const data = "A".repeat(Math.ceil(bytes / 3) * 4) + const h = await harness({ audio: { data, mimeType: "audio/wav" } }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-32602) + // Both configured models are rejected, for DIFFERENT reasons: the + // text-only one cannot take audio at all, the audio-capable one is over + // the size cap. Assert the size verdict on the model it applies to. + const audioCapable = detail.data.rejected.find((item: any) => item.model === `${PROVIDER_ID}/mimo-v2.5`) + expect(audioCapable.reason).toMatch(/over the .* byte limit for audio/) + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }) + }, 60_000) + + // The SDK validates AudioContent.data against its own Base64 refinement while + // parsing the inbound request, so a malformed payload is refused at the protocol + // boundary and never reaches our handler. Our own base64 check (asserted in + // sampling.test.ts) is the defence-in-depth layer behind it. + test("invalid base64 audio is refused at the protocol boundary, before any model work", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const h = await harness({ audio: { data: "!!!not-base64!!!", mimeType: "audio/wav" } }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.message).toMatch(/Invalid Base64 string/) + // Nothing reached a provider. + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }) + }, 60_000) + + test("a cancelled sampling request is answered with a cancellation error, not left hanging", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + // Policy defaults to ask, so the request parks on human approval — a + // deterministic point at which to cancel it. + const h = await harness({ text: "hi", cancelAfterAsk: true }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + // The server's own request settled rather than hanging to its timeout. + const detail = h.samplingOutcomes[0].detail as any + expect(String(detail.message)).toMatch(/cancel/i) + // No model call was ever made. + expect(wire!.bodies).toHaveLength(0) + // The fiber drains. This is polled, not asserted synchronously: the + // server's request rejects on its own abort immediately, while our side + // only unwinds once `notifications/cancelled` arrives and aborts the + // handler's signal, so the two are not ordered. + await drainInFlight(h.client) + await h.client.close() + }) + }, 60_000) + + /** + * THE SILENCE BOUND, and the reaping it is responsible for. Cancellation (above) + * was the only exercised exit from a parked request; this exit was implemented and + * untested. It matters more than a redundant second exit, because the upstream SDK + * drops a cancellation whose JSON-RPC id is 0 (pinned in the last describe block of + * this file), which makes this the ONLY thing that reaps the first server-initiated + * sampling request of a connection when the server abandons it. + * + * IT USED TO BE THE TOTAL BOUND THAT DID THIS REAPING, and the rename in this test + * is not cosmetic: the total bound is gone, so what now catches a provider that + * never answers is the stall detector, and the error it produces names + * `phase: "stall"` and reports how much output arrived. That last field is the part + * a total bound could never have supplied — `chunks: 0` says the provider never + * produced anything, which is precisely the distinction this exit exists to draw. + * + * The bound is INJECTED (1 s) because a test cannot wait out the inherited 8 + * minutes. Production passes nothing and takes the provider's own `chunkTimeout`; + * that the inherited value is genuinely what applies is proven separately by the + * config test in the deadlines block, which injects nothing at all. + */ + test("a provider that never responds is reaped at the silence bound and leaves the in-flight set", async () => { + const BOUND = 1_000 + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + // Warm the provider and the bundled adapter on a client with the DEFAULT + // bound, so the tight bound below is spent waiting on the provider rather + // than racing a cold module load. + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + const ok = await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(ok.isError).toBeFalsy() + await first.client.close() + } finally { + warm.restore() + } + + const hang = stubProviderHang() + wire = hang + try { + const h = await harness({ text: "hi" }) + await wireSampling(h.client, "fixture", undefined, BOUND) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + // Far longer than BOUND. If OUR bound stopped firing, the SDK's own + // request timeout would fire here instead — a different error, which the + // assertions below reject by name rather than by "something timed out". + { timeout: 20_000 }, + ) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(McpSampling.TIMEOUT_CODE) + // OURS: the SDK's own timeout says "Request timed out" and carries no + // `server`, so this pair cannot be satisfied by the SDK's error. + expect(String(detail.message)).toMatch(/sampling stalled: the model produced no output/) + expect(detail.data).toMatchObject({ server: "fixture", phase: "stall", timeout: BOUND }) + // NEVER STARTED, not "started and went quiet" — the distinction the removed + // total bound could not express. + expect(detail.data.chunks).toBe(0) + + // The model call was genuinely started and then abandoned mid-flight — + // this is the expiry path, not a pre-flight refusal. + expect(hang.bodies).toHaveLength(1) + + // THE BOUND MUST REACH THE PROVIDER, not just our own waiting. Interrupting + // the call fiber does NOT by itself cancel a promise already in flight, so + // without the abort composition in `handle` the HTTP call would run to + // completion after we gave up on it — exactly the leak this asserts against. + // Observed on the signal the provider was handed, so it cannot be satisfied + // by our own bookkeeping. + expect(hang.signals).toHaveLength(1) + expect(hang.signals[0]).toBeInstanceOf(AbortSignal) + expect(hang.signals[0]!.aborted).toBe(true) + + // THE POINT OF THE GAP: the expired fiber is removed from the in-flight + // set. `serve`'s finally block runs before the JSON-RPC error is written, + // so this is asserted SYNCHRONOUSLY; polling would also pass while a leak + // drained on its own. + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + } finally { + hang.release() + } + }) + }, 60_000) + + test("cancelAll interrupts sampling still in flight so the server stops waiting", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const pending = h.client + .callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { timeout: 30_000 }) + .catch(() => undefined) + // Park on the approval prompt, then tear the sampling work down underneath + // it — the client-exit path src/mcp/index.ts runs from closeClient. + await waitForAsk() + expect(McpSampling.inFlightCount(h.client)).toBe(1) + await AppRuntime.runPromise(McpSampling.cancelAll(h.client)) + + // The OUTCOME, not the bookkeeping: the server's own sampling request must + // settle with an error. Asserting only that inFlightCount dropped to 0 + // would pass even if the interrupt were a no-op, because cancelAll clears + // its tracking set either way. + for (let attempt = 0; attempt < 200 && h.samplingOutcomes.length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + // The interrupted request never reached a provider. + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + await pending + }) + }, 60_000) + + /** + * The case the test above cannot cover: `cancelAll` while the request is parked + * on the PROVIDER rather than on the approval prompt. `Fiber.interrupt` stops our + * fiber, but a promise already in flight inside it keeps running unless something + * aborts it — so without the abort composition in `handle` this teardown would + * leave a live model call behind with no owner. `sampling: "allow"` removes the + * prompt so the request is guaranteed to be inside `generateText` when cancelled. + */ + test("cancelAll aborts a provider call already in flight", async () => { + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const hang = stubProviderHang() + wire = hang + try { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const pending = h.client + .callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { timeout: 30_000 }) + .catch(() => undefined) + + // Park INSIDE the provider call, not before it. + for (let attempt = 0; attempt < 400 && hang.bodies.length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(hang.bodies).toHaveLength(1) + expect(hang.signals[0]).toBeInstanceOf(AbortSignal) + // Not yet aborted: proves the assertion below observes the cancellation + // rather than a signal that was already aborted for some other reason. + expect(hang.signals[0]!.aborted).toBe(false) + expect(McpSampling.inFlightCount(h.client)).toBe(1) + + await AppRuntime.runPromise(McpSampling.cancelAll(h.client)) + + // THE OUTCOME AT THE PROVIDER: the HTTP call was aborted, so no orphaned + // model call outlives the client that owned it. + for (let attempt = 0; attempt < 200 && !hang.signals[0]!.aborted; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(hang.signals[0]!.aborted).toBe(true) + + // And the server still gets an answer instead of hanging to its own timeout. + for (let attempt = 0; attempt < 200 && h.samplingOutcomes.length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + await pending + } finally { + hang.release() + } + }) + }, 60_000) + + /** + * FAIL-OPEN GUARD. `mcp..sampling: "allow"` skips the approval prompt, so + * an explicit `permission.mcp_sampling` deny is never seen by `permission.ask` at + * all. Unless `handle` evaluates the ruleset itself, the deny is silently + * discarded and the model runs — a security control that reads as configured and + * does nothing. + */ + test("an explicit permission deny wins over mcp..sampling allow", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance( + config({ + mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } }, + permission: { mcp_sampling: { "*": "allow", fixture: "deny" } }, + }), + async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(false) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(McpSampling.REJECTED_CODE) + expect(String(detail.message)).toMatch(/sampling is denied/) + // Names WHICH control refused, so this cannot be satisfied by the + // pre-existing `mcp..sampling: "deny"` branch. + expect(detail.data).toMatchObject({ server: "fixture", deniedBy: "permission.mcp_sampling" }) + // Refused before any model work, not after. + expect(wire!.bodies).toHaveLength(0) + expect(McpSampling.inFlightCount(h.client)).toBe(0) + await h.client.close() + }, + ) + }, 60_000) + + test("a server that never samples keeps working unchanged", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const server = new McpServer({ name: "plain", version: "1.0.0" }) + server.registerTool("echo", { description: "echo", inputSchema: { value: z.string() } }, async (args) => ({ + content: [{ type: "text", text: String((args as { value: string }).value) }], + })) + const client = new Client({ name: "mimocode", version: "test" }, MCP.CLIENT_OPTIONS) + const [a, b] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(a), server.server.connect(b)]) + await wireSampling(client, "plain") + const result = await client.callTool({ name: "echo", arguments: { value: "unchanged" } }, CallToolResultSchema) + expect((result.content as Array<{ text: string }>)[0].text).toBe("unchanged") + expect(wire!.bodies).toHaveLength(0) + await client.close() + }) + }, 60_000) +}) + +/** + * DEADLINES AND KEEPALIVE. + * + * One wall-clock bound used to wrap a human decision and a machine call together, + * and nothing kept the counterparty's own timer alive. These tests pin the symptoms + * of that, not the bookkeeping around it. + * + * WHAT IS LEFT TO BOUND, after three invented bounds were removed: only SILENCE + * FROM THE PROVIDER, and its value is the provider layer's own `chunkTimeout` + * rather than a number sampling picked. There is no total bound and no approval + * bound, so two of the expiries these tests used to distinguish no longer exist. + * + * THE SILENCE BOUND IS INJECTED HERE so the suite runs in CI time. That is a + * property of the tests, not of the proofs: production passes nothing and inherits + * `DEFAULT_CHUNK_TIMEOUT` (8 minutes), or whatever the operator configured for that + * provider. Sub-second bounds prove the MECHANISM, never that a production value is + * right; the inherited value is pinned separately by a constant assertion, and no + * test that finishes in seconds also waits out eight minutes. + */ +describe("sampling deadlines and liveness", () => { + test("a liveness notification is emitted while the model call is in flight", async () => { + // 400 ms beats inside a 2.5 s silence bound: several land, and the count is + // asserted as a floor so a slow CI box cannot fail it. + const CHUNK_BOUND = 2_500 + const INTERVAL = 400 + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + + const hang = stubProviderHang() + wire = hang + try { + const seen: Array = [] + const h = await harness({ + text: "hi", + // The server OPTS IN. This is what makes the SDK mint a token at all. + onprogress: (progress) => seen.push(progress), + // Generous, so the server's own timer is not what ends this. + requestTimeout: 30_000, + }) + await wireSampling(h.client, "fixture", INTERVAL, CHUNK_BOUND) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + + // THE SYMPTOM: notifications genuinely crossed the wire. Observed on the + // server's transport, so our own counters cannot satisfy this. + expect(h.progressNotifications.length).toBeGreaterThanOrEqual(2) + // And the server's own handler ran, i.e. the token we echoed back matched + // the one it minted — an unmatched token lands in `_onprogress`'s + // "unknown token" error branch instead. + expect(seen.length).toBeGreaterThanOrEqual(2) + + const first = h.progressNotifications[0].params + // THE FALSY-ZERO TRAP, guarded on purpose: this is the first + // server-initiated request of the connection, so its message id — and + // therefore its progress token — is `0`. The SDK's own cancel path drops + // `requestId` 0 for exactly this reason; our token check must test for + // `undefined`, not for truthiness. + expect(first.progressToken).toBe(0) + // LIVENESS FROM EVIDENCE: a monotonic tick and NO `total`, because + // streaming still cannot say how many chunks are coming, so no fraction is + // computable and none is implied. The message reports what was OBSERVED — + // and this provider answered nothing at all, so it must say so rather than + // claim output. `chunks === 0` is the distinction the whole signal exists + // for; counting the SDK's own `start` part would have reported "1 chunk" + // for this stone-dead call. + expect(first.progress).toBe(1) + expect(first.total).toBeUndefined() + expect(String(first.message)).toMatch(/no output yet/) + expect(String(first.message)).not.toMatch(/streaming/) + // And no model text rode along on the progress channel. Nothing was + // produced here, but the assertion is about the CHANNEL, not this call: + // partial content is deliberately never sent — see `heartbeat`. + for (const notification of h.progressNotifications) { + expect(String(notification.params.message)).not.toMatch(/quick brown fox/) + } + const ticks = h.progressNotifications.map((n) => n.params.progress) + expect(ticks).toEqual([...ticks].sort((a, b) => a - b)) + expect(new Set(ticks).size).toBe(ticks.length) + + // AND IT CANNOT MASK A HUNG CALL. Beats went out the whole time and the + // silence bound still fired: the notifications reset the PEER's timer, never + // ours. Asserted on the error the server actually received. `phase: "stall"` + // is now the ONLY expiry a model call can produce — `"model"` and `"total"` + // were removed with their bounds. + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(McpSampling.TIMEOUT_CODE) + expect(detail.data).toMatchObject({ server: "fixture", phase: "stall", timeout: CHUNK_BOUND }) + expect(hang.signals[0]!.aborted).toBe(true) + await h.client.close() + } finally { + hang.release() + hang.restore() + } + }) + }, 60_000) + + test("no liveness notification is emitted when the server supplied no progress token", async () => { + const CHUNK_BOUND = 1_500 + const INTERVAL = 200 + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + + const hang = stubProviderHang() + wire = hang + try { + // NO `onprogress`, so no `_meta.progressToken` exists on the request. + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + // Interval far below the bound, so "nothing was sent" is a real absence + // and not simply a window too short for the first beat to fall in. + await wireSampling(h.client, "fixture", INTERVAL, CHUNK_BOUND) + const errors: Array = [] + h.server.server.onerror = (error) => errors.push(error) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + // The model call really did run long enough for many beats to have fired + // had we been sending any. + expect(hang.bodies).toHaveLength(1) + expect(h.progressNotifications).toHaveLength(0) + // A notification sent against a token the peer never minted is not merely + // wasted: `_onprogress` reports it to the peer as an error. Nothing did. + expect(errors).toHaveLength(0) + await h.client.close() + } finally { + hang.release() + hang.restore() + } + }) + }, 60_000) + + test("an unanswered approval is NOT timed out: the prompt stays pending and a late answer still succeeds", async () => { + // THE BOUND THIS REPLACES. A 30 s wall-clock bound used to end the approval + // wait and report `phase: "approval"`. `permission/index.ts` has no such bound + // for an ordinary interactive ask — only a FORWARDED ask + // (FORWARD_DENY_TIMEOUT_MS) and a skip-all forced ask are bounded, and this ask + // is neither — so a TUI prompt waits indefinitely while sampling gave up. + // + // PROVING AN ABSENCE needs a positive observation, not a longer wait: the + // request must still be ALIVE after a stretch in which the old bound (had it + // been injectable, which it no longer is) would have killed it, and it must + // still be answerable. Both halves are asserted, so a machine that somehow + // resolved the prompt early fails the test rather than proving less. + const PENDING_WINDOW = 1_800 + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + wire = stubProvider(TRANSCRIPT) + // Generous peer timeout: the SERVER's own timer must not be what ends this, + // or the test would be measuring the SDK rather than us. + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + await wireSampling(h.client) + const call = h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + const ask = await waitForAsk() + + // Deliberately answer NOTHING for a window several times the poll interval + // and well past the old 30 s bound's shape at test scale. + await new Promise((resolve) => setTimeout(resolve, PENDING_WINDOW)) + + // STILL PENDING, and the model was never charged for the wait. + const stillPending = await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }), + ) + expect(stillPending.some((item) => item.id === ask.id)).toBe(true) + expect(McpSampling.inFlightCount(h.client)).toBe(1) + expect((wire as Wire).bodies).toHaveLength(0) + // No error reached the server: nothing expired. + expect(h.samplingOutcomes).toHaveLength(0) + + // A LATE ANSWER STILL WORKS. Under the old bound this reply arrived after the + // request had already been failed, so the transcript below could not exist. + await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + yield* permission.reply({ requestID: ask.id, reply: "once" }) + }), + ) + const result = await call + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ text: string }>)[0].text).toBe(TRANSCRIPT) + expect(h.samplingOutcomes[0].ok).toBe(true) + expect((wire as Wire).bodies).toHaveLength(1) + await h.client.close() + }) + }, 90_000) + + test("a per-provider chunkTimeout from mimocode.json is what bounds a silent sampling call", async () => { + // THE REUSE, END TO END AND WITHOUT AN INJECTED PARAMETER. `wireSampling` passes + // no bound here, so the value can only have come from the operator's provider + // config — the same `chunkTimeout` key `provider.ts` reads for the main chat + // path. That is the whole point of deleting DEFAULT_SAMPLING_STALL_TIMEOUT: one + // knob, one value, no second number to drift. + const CONFIGURED = 700 + const cfg = config({ + mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } }, + provider: { + [PROVIDER_ID]: { + ...PROVIDERS[PROVIDER_ID], + options: { ...PROVIDERS[PROVIDER_ID].options, chunkTimeout: CONFIGURED }, + }, + }, + }) + await withInstance(cfg, async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + const hang = stubProviderHang() + wire = hang + try { + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + // NO bound injected — production wiring exactly. + await wireSampling(h.client) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + // THE CONFIGURED VALUE IS THE ONE REPORTED. Had sampling kept its own + // default this would read 45_000; had it ignored config it would read + // 480_000 and this test would have timed out instead. + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(McpSampling.TIMEOUT_CODE) + expect(detail.data).toMatchObject({ server: "fixture", phase: "stall", timeout: CONFIGURED }) + expect(hang.bodies).toHaveLength(1) + expect(hang.signals[0]!.aborted).toBe(true) + await h.client.close() + } finally { + hang.release() + hang.restore() + } + }) + }, 60_000) + + test("a chunk timeout of 0 disables the silence bound instead of firing instantly", async () => { + // `provider.ts` treats 0 as "install no bound" (it creates no AbortController; + // its comment says "incl. 0 / negative to disable"). Sampling has to mean the + // same thing by it, and the failure mode if it does not is severe rather than + // cosmetic: `stallWatch` with `stallMs = 0` satisfies `Date.now() - lastAt >= 0` + // on its first poll, so a 0 arriving here would kill every sampling call + // immediately instead of removing a bound. + // + // ⚠️0 IS INJECTED RATHER THAN CONFIGURED, and that is a finding rather than a + // convenience. `chunkTimeout` is declared `PositiveInt` + // (`config/provider.ts:5,111`, i.e. `isGreaterThan(0)`), so `chunkTimeout: 0` is + // REJECTED BY THE CONFIG SCHEMA and no operator can write it in mimocode.json — + // which makes provider.ts's own "0 / negative to disable" affordance unreachable + // from config too. Measured, not assumed: configuring 0 here made the provider + // unresolvable and no HTTP call went out at all. So this guards the value + // arriving through the parameter, which is the only route that exists. + const ALIVE_WINDOW = 1_200 + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + const hang = stubProviderHang() + wire = hang + try { + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + await wireSampling(h.client, "fixture", undefined, 0) + const call = h.client + .callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { timeout: 30_000 }) + .catch(() => undefined) + await new Promise((resolve) => setTimeout(resolve, ALIVE_WINDOW)) + // The provider call went out and is STILL RUNNING: no bound was installed. + // With a `stallMs = 0` watcher this would have been aborted on the first poll, + // ~25 ms in, and `samplingOutcomes` would already hold a stall error. + expect(hang.bodies).toHaveLength(1) + expect(hang.signals[0]!.aborted).toBe(false) + expect(McpSampling.inFlightCount(h.client)).toBe(1) + expect(h.samplingOutcomes).toHaveLength(0) + // Let it finish so the tool call settles rather than leaking a promise. + hang.release() + await call + await h.client.close() + } finally { + hang.release() + hang.restore() + } + }) + }, 60_000) + + // ⚠️THIS TEST IS A CHANGE-DETECTOR, NOT A PROOF OF BEHAVIOUR, and it is labelled + // as such rather than counted as coverage: every assertion below compares a + // constant against another constant or a literal, so the only way to make it fail + // is to edit what it pins. Nothing here exercises a code path, and no revert probe + // exists for it because there is no mechanism to revert. What it guards is the one + // thing a comment cannot: that the silence bound does not quietly become a second + // number again, and that the three deleted bounds do not come back. + test("the liveness interval is sampling's only self-chosen number, and the silence bound is the provider's", () => { + expect(McpSampling.DEFAULT_LIVENESS_INTERVAL).toBe(15_000) + // Several beats must fit inside one peer timeout window (the SDK's 60 s + // DEFAULT_REQUEST_TIMEOUT_MSEC). That is now the whole of this number's job: it + // used to be justified against the stall bound as well, and that ratio is void. + expect(McpSampling.DEFAULT_LIVENESS_INTERVAL * 3).toBeLessThan(60_000) + + // THE SAME VALUE, NOT A SECOND NUMBER. With nothing configured and nothing + // injected, sampling's silence bound IS the provider layer's, so the two cannot + // drift apart the way 45 s and 480 s had. + expect(McpSampling.chunkTimeoutFor({}, PROVIDER_ID)).toBe(DEFAULT_CHUNK_TIMEOUT) + expect(DEFAULT_CHUNK_TIMEOUT).toBe(480_000) + + // AND THE DELETED BOUNDS STAY DELETED. Each was a number with no precedent in + // this repo, so re-exporting any of them would mean one had come back. + expect(Object.keys(McpSampling)).not.toContain("DEFAULT_SAMPLING_TIMEOUT") + expect(Object.keys(McpSampling)).not.toContain("DEFAULT_SAMPLING_STALL_TIMEOUT") + expect(Object.keys(McpSampling)).not.toContain("DEFAULT_SAMPLING_APPROVAL_TIMEOUT") + }) +}) + +/** + * STREAMING, AND THE STALL SIGNAL IT MAKES POSSIBLE. + * + * A non-streaming model call is opaque: "has this hung?" is unanswerable from + * inside it, so the only available defence was a total wall-clock bound and a + * guessed number to fill it. These tests pin the two things streaming buys — + * liveness reported from OBSERVED output, and a stall detector that fires on real + * silence — plus the thing it must NOT change, which is the single-result response + * contract a server sees. + * + * EVERY BOUND HERE IS INJECTED so the suite runs in CI time, exactly as in the + * block above: production passes none and inherits the provider's `chunkTimeout` + * (8 minutes by default), with no total bound behind it. Sub-second bounds prove + * the MECHANISM, never that any particular production value is right; the inherited + * value is pinned separately by the constant assertions, and no test that finishes + * in seconds also waits out eight minutes. + */ +describe("sampling streams, and a stalled stream is observable", () => { + test("the model call streams, and the single-result contract is unchanged", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const h = await harness({ text: "hi" }) + await wireSampling(h.client) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + // THE SWITCH ITSELF, asserted FIRST and observed at the PROVIDER's HTTP + // boundary rather than by inspecting our own call site: a streaming request + // is what went out. Checked before the result so that a non-streaming + // regression is reported as "the request was not a stream" rather than as + // some downstream symptom of it. + expect(wire!.bodies).toHaveLength(1) + expect(wire!.bodies[0].stream).toBe(true) + // And the fixture really did answer in several deltas, so the assembled text + // below was concatenated from a stream and not delivered in one piece. + expect(splitDeltas(TRANSCRIPT).length).toBeGreaterThan(1) + expect(result.isError).toBeFalsy() + + // THE CONTRACT DID NOT MOVE. Still one `CreateMessageResult`, still text-only, + // still the same four fields with the same values — the server cannot tell + // from its result that anything changed, which is the point. + const detail = h.samplingOutcomes[0].detail as any + expect(h.samplingOutcomes).toHaveLength(1) + expect(h.samplingOutcomes[0].ok).toBe(true) + expect(detail.role).toBe("assistant") + expect(detail.content).toEqual({ type: "text", text: TRANSCRIPT }) + expect(detail.model).toBe(`${PROVIDER_ID}/mimo-v2.5`) + expect(detail.stopReason).toBe("endTurn") + // No extra field crept in alongside the streaming change. + expect(Object.keys(detail).sort()).toEqual(["content", "model", "role", "stopReason"]) + await h.client.close() + }) + }, 60_000) + + test("liveness reports OBSERVED output, and never the model's text", async () => { + // Slow enough that several beats land mid-stream, so the notifications describe + // a call in progress rather than one already finished. + const INTERVAL = 150 + const trickle = stubProviderTrickle({ deltas: splitDeltas(TRANSCRIPT), gapMs: 120 }) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + + wire = trickle + try { + const h = await harness({ text: "hi", onprogress: () => {}, requestTimeout: 30_000 }) + await wireSampling(h.client, "fixture", INTERVAL, 20_000) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + // The call SUCCEEDED, so these beats were emitted around real output. + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ text: string }>)[0].text).toBe(TRANSCRIPT) + + expect(h.progressNotifications.length).toBeGreaterThanOrEqual(2) + const messages = h.progressNotifications.map((n) => String(n.params.message)) + // THE UPGRADE OVER A FIXED TICK: at least one beat reports output the + // provider actually produced. A timer-driven tick cannot say this, which is + // why it could not tell "our process is alive" from "the model is working". + expect(messages.some((m) => /model streaming, \d+ chunks \/ \d+ characters/.test(m))).toBe(true) + // Counts only ever go forward, and the last beat saw real characters. + const counts = messages + .map((m) => m.match(/(\d+) chunks \/ (\d+) characters/)) + .filter((m): m is RegExpMatchArray => m !== null) + .map((m) => [Number(m[1]), Number(m[2])] as const) + expect(counts.length).toBeGreaterThanOrEqual(1) + const chunkCounts = counts.map(([c]) => c) + expect(chunkCounts).toEqual(chunkCounts.slice().sort((a, b) => a - b)) + expect(counts.at(-1)![1]).toBeGreaterThan(0) + + // AND NO PARTIAL CONTENT WENT OUT. `onprogress` asks for liveness, not for + // output, and a request that later fails delivers no text at all — so a + // prefix on this channel would disclose in the failure case exactly what + // the contract says was never delivered. Checked word by word so no single + // delta leaked either. + for (const message of messages) { + for (const word of TRANSCRIPT.split(" ")) expect(message).not.toContain(word) + } + expect(h.progressNotifications.every((n) => n.params.total === undefined)).toBe(true) + await h.client.close() + } finally { + trickle.release() + trickle.restore() + } + }) + }, 90_000) + + test("a stalled stream is reported as a stall, distinctly from the model bound, and says whether output ever started", async () => { + const CHUNK_BOUND = 700 + + // PHASE 1 — the provider never answers at all. Nothing is produced, so the + // stall must say `chunks: 0`. + let never: any + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + const hang = stubProviderHang() + wire = hang + try { + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + // Model bound and approval bound left LARGE on purpose: if the stall + // detector did not exist, one of those would have to be what ends this, and + // the assertions below would see `phase: "model"` or `phase: "total"`. + await wireSampling(h.client, "fixture", 10_000, CHUNK_BOUND) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + never = h.samplingOutcomes[0].detail + expect(hang.bodies).toHaveLength(1) + // THE STALL REACHES THE PROVIDER, not just our own waiting for it. + expect(hang.signals[0]!.aborted).toBe(true) + await h.client.close() + } finally { + hang.release() + hang.restore() + } + }) + + // PHASE 2 — the provider answers, streams a few deltas, then goes quiet + // forever. Output DID start, so the stall must say so. + let died: any + const trickle = stubProviderTrickle({ deltas: ["the ", "quick ", "brown "], gapMs: 80, thenSilent: true }) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + wire = trickle + try { + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + await wireSampling(h.client, "fixture", 10_000, CHUNK_BOUND) + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + expect(result.isError).toBe(true) + died = h.samplingOutcomes[0].detail + await h.client.close() + } finally { + trickle.release() + trickle.restore() + } + }) + + // Both are still `-32001` and both still carry `data.server` — that field is + // the ONLY discriminator against the SDK's own `-32001`, since `data.timeout` + // is set by our bounds too. + expect(never.code).toBe(McpSampling.TIMEOUT_CODE) + expect(died.code).toBe(McpSampling.TIMEOUT_CODE) + expect(never.data).toMatchObject({ server: "fixture", phase: "stall", timeout: CHUNK_BOUND, chunks: 0 }) + expect(died.data).toMatchObject({ server: "fixture", phase: "stall", timeout: CHUNK_BOUND }) + // THE OBSERVABILITY PAYOFF, and the reason this is not merely a shorter + // timeout: the error itself distinguishes a provider that never produced + // anything from one that produced and then died. + expect(never.data.chunks).toBe(0) + expect(never.data.characters).toBe(0) + expect(died.data.chunks).toBeGreaterThan(0) + expect(died.data.characters).toBeGreaterThan(0) + // `"stall"` IS NOW THE WHOLE PHASE VOCABULARY. The three expiries this used to + // be distinguished from are gone with their bounds, so these are no longer + // "distinct from a sibling" checks but a guard that none of them comes back + // wearing this error's clothes. Asserted positively as well, so the check cannot + // pass merely because `phase` went missing. (Matched, not compared: `McpError` + // prefixes `MCP error -32001: ` on each hop.) + for (const detail of [never, died]) { + expect(String(detail.message)).toMatch(/sampling stalled: the model produced no output/) + expect(String(detail.message)).not.toMatch(/waiting for the model/) + expect(String(detail.message)).not.toMatch(/waiting for approval/) + expect(detail.data.phase).toBe("stall") + expect(detail.data.phase).not.toBe("model") + expect(detail.data.phase).not.toBe("total") + expect(detail.data.phase).not.toBe("approval") + } + }, 120_000) + + test("a slow stream is not a stalled one: every chunk resets the clock", async () => { + // The stream runs for MANY TIMES the stall bound in total while never pausing + // for as long as the bound. That combination is the whole property: a bound on + // the GAP, not on the duration. Were the clock not reset per chunk, this call + // would be killed at ~CHUNK_BOUND despite producing output the entire time. + const CHUNK_BOUND = 900 + const GAP = 250 + const deltas = splitDeltas(TRANSCRIPT) + expect(deltas.length * GAP).toBeGreaterThan(CHUNK_BOUND * 2) + const trickle = stubProviderTrickle({ deltas, gapMs: GAP }) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"], sampling: "allow" } } }), async () => { + const warm = stubProvider(TRANSCRIPT) + try { + const first = await harness({ text: "hi" }) + await wireSampling(first.client) + await first.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + await first.client.close() + } finally { + warm.restore() + } + wire = trickle + try { + const h = await harness({ text: "hi", requestTimeout: 30_000 }) + await wireSampling(h.client, "fixture", 10_000, CHUNK_BOUND) + const started = Date.now() + const result = await h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + const elapsed = Date.now() - started + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ text: string }>)[0].text).toBe(TRANSCRIPT) + // It genuinely outlived the stall bound, so "it did not stall" is a real + // result and not an artefact of the call finishing too fast to test. + expect(elapsed).toBeGreaterThan(CHUNK_BOUND) + expect(h.samplingOutcomes[0].ok).toBe(true) + await h.client.close() + } finally { + trickle.release() + trickle.restore() + } + }) + }, 90_000) +}) + +describe("the approval prompt", () => { + test("carries server, model, content types and audio size, and no credentials", async () => { + wire = stubProvider(TRANSCRIPT) + const buffer = wav(2) + const data = buffer.toString("base64") + await withInstance( + // No `permission` entry at all, and no per-server `sampling` policy, so + // mcp_sampling defaults to ask and a real prompt must be published. + config({ mcp: { fixture: { type: "local", command: ["true"] } } }), + async () => { + const h = await harness({ audio: { data, mimeType: "audio/wav" }, hints: [{ name: "mimo-v2.5" }] }) + await wireSampling(h.client) + + // Start the call WITHOUT awaiting: it cannot finish until the prompt is + // answered, which is the behaviour under test. + const pending = h.client.callTool( + { name: "transcribe_audio_fixture", arguments: {} }, + CallToolResultSchema, + { timeout: 30_000 }, + ) + + const request = await waitForAsk() + expect(request.permission).toBe("mcp_sampling") + expect(request.patterns).toEqual(["fixture"]) + expect(request.metadata).toMatchObject({ + server: "fixture", + model: `${PROVIDER_ID}/mimo-v2.5`, + requestedModel: ["mimo-v2.5"], + audio: [{ mimeType: "audio/wav", bytes: buffer.length }], + systemPrompt: "You are a verbatim transcription engine.", + textPrompt: "Transcribe this audio verbatim.", + maxTokens: 2048, + }) + expect([...(request.metadata.contentTypes as string[])].sort()).toEqual(["audio", "text"]) + + // No credential, base URL or raw audio payload in the prompt. + const serialized = JSON.stringify(request) + expect(serialized).not.toContain("test-key") + expect(serialized).not.toContain("example.invalid") + expect(serialized).not.toContain(data.slice(0, 64)) + + await AppRuntime.runPromise( + Effect.gen(function* () { + const permission = yield* Permission.Service + yield* permission.reply({ requestID: request.id, reply: "once" }) + }), + ) + + const result = await pending + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ text: string }>)[0].text).toBe(TRANSCRIPT) + await h.client.close() + }, + ) + }, 60_000) + + test("a session-less sampling request under policy ask fails closed instead of hanging", async () => { + wire = stubProvider(TRANSCRIPT) + await withInstance(config({ mcp: { fixture: { type: "local", command: ["true"] } } }), async () => { + const h = await harness({ text: "hi" }) + // Deliberately NOT calling setActiveSession: no turn is in flight, so an + // `ask` would publish a prompt nothing is listening for. + await AppRuntime.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + McpSampling.serve("fixture", h.client as never, bridge) + }), + ) + const result = await h.client.callTool({ name: "transcribe_audio_fixture", arguments: {} }, CallToolResultSchema, { + timeout: 30_000, + }) + expect(result.isError).toBe(true) + const detail = h.samplingOutcomes[0].detail as any + expect(detail.code).toBe(-1) + expect(detail.message).toMatch(/no active session/) + expect(wire!.bodies).toHaveLength(0) + await h.client.close() + }) + }, 60_000) +}) + +/** + * UPSTREAM SDK BEHAVIOUR, PINNED — @modelcontextprotocol/sdk 1.27.1. + * + * `Protocol._oncancel` opens with `if (!notification.params.requestId) return` + * (dist/esm/shared/protocol.js:170), so a `notifications/cancelled` naming request + * id **0** is silently dropped and the receiving handler's `extra.signal` is never + * aborted. `_requestMessageId` is initialised to 0 (protocol.js:16) and is PER + * PROTOCOL INSTANCE, counting only the requests that instance SENDS — so the id at + * risk belongs to the SERVER's outgoing counter, and nothing our client does can + * advance it. Consequence for us: the FIRST server-initiated sampling request of a + * connection cannot be cancelled by the server, and the request-timeout bound is + * the only thing that reaps it. + * + * These tests exist so an SDK upgrade is VISIBLE. If upstream drops the falsy + * check, cases 1 and 2 flip to `aborted === true` and fail here rather than + * quietly changing behaviour under us. + */ +describe("upstream: a cancellation for JSON-RPC id 0 is dropped by the SDK", () => { + const settle = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + interface Pin { + client: ClientType + server: McpServerType + /** Signals handed to the client's sampling handler, in arrival order. */ + signals: AbortSignal[] + /** Every JSON-RPC message the SERVER put on the wire. */ + sent: Array + close: () => Promise + } + + /** + * A real client whose `sampling/createMessage` handler PARKS, so a server + * cancellation arrives while the request is genuinely open. A bare SDK handler + * on purpose: what is under test is the SDK's notification routing, not ours. + */ + async function pin(): Promise { + const server = new McpServer({ name: "cancelpin", version: "1.0.0" }) + const client = new Client({ name: "mimocode", version: "test" }, MCP.CLIENT_OPTIONS) + const signals: AbortSignal[] = [] + const release: Array<() => void> = [] + client.setRequestHandler(CreateMessageRequestSchema, (async (_request: any, extra: any) => { + signals.push(extra.signal) + await new Promise((resolve) => release.push(resolve)) + return { role: "assistant", content: { type: "text", text: "unused" }, model: "m", stopReason: "endTurn" } + }) as never) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const sent: Array = [] + const send = serverTransport.send.bind(serverTransport) + serverTransport.send = ((message: any, options: any) => { + sent.push(message) + return send(message, options) + }) as never + await Promise.all([client.connect(clientTransport), server.server.connect(serverTransport)]) + return { + client, + server, + signals, + sent, + close: async () => { + for (const resolve of release.splice(0)) resolve() + await client.close() + }, + } + } + + /** Issue a server→client sampling request, then abandon it. */ + async function abandonSamplingRequest(p: Pin) { + const controller = new AbortController() + const outcome = p.server.server + .request( + { + method: "sampling/createMessage", + params: { messages: [{ role: "user", content: { type: "text", text: "hi" } }], maxTokens: 16 }, + }, + CreateMessageResultSchema, + { signal: controller.signal, timeout: 20_000 }, + ) + .then( + () => "resolved", + () => "rejected", + ) + // Do not cancel before the handler has been entered, or there would be no + // abort controller registered to find and the test would prove nothing. + for (let attempt = 0; attempt < 200 && p.signals.length === 0; attempt++) await settle(10) + expect(p.signals).toHaveLength(1) + controller.abort(new Error("server abandoned the request")) + expect(await outcome).toBe("rejected") + // Let the notification cross the in-memory transport. + await settle(100) + } + + /** The requestId the server named in its `notifications/cancelled`. */ + function cancelledId(p: Pin) { + const notification = p.sent.find((message) => message?.method === "notifications/cancelled") + expect(notification).toBeDefined() + return notification.params.requestId + } + + test("case 1: the FIRST server-initiated request is id 0 and its cancellation never aborts our signal", async () => { + const p = await pin() + await abandonSamplingRequest(p) + expect(cancelledId(p)).toBe(0) + // The bug, measured: the notification was sent and delivered, and the + // receiving handler's signal is still live. + expect(p.signals[0].aborted).toBe(false) + await p.close() + }, 30_000) + + test("case 2: a CLIENT-side request first does not help — the server's counter is still at 0", async () => { + const p = await pin() + // A client→server round trip. This is what "burn id 0 at connection setup" + // would amount to from our side, and it advances OUR outgoing counter, not + // the server's — so it cannot make the server's cancellations land. + await p.client.ping() + await abandonSamplingRequest(p) + expect(cancelledId(p)).toBe(0) + expect(p.signals[0].aborted).toBe(false) + await p.close() + }, 30_000) + + test("case 3: once the SERVER has spent id 0, the very same cancellation works", async () => { + const p = await pin() + // Only the server can advance its own outgoing id. + await p.server.server.ping() + await abandonSamplingRequest(p) + expect(cancelledId(p)).toBe(1) + // The control for cases 1 and 2: cancellation delivery works end to end, so + // their `aborted === false` is the falsy-id check and nothing else. + expect(p.signals[0].aborted).toBe(true) + await p.close() + }, 30_000) +}) diff --git a/packages/opencode/test/mcp/sampling.test.ts b/packages/opencode/test/mcp/sampling.test.ts new file mode 100644 index 000000000..f1b581fde --- /dev/null +++ b/packages/opencode/test/mcp/sampling.test.ts @@ -0,0 +1,281 @@ +import { test, expect, describe } from "bun:test" +import { McpSampling } from "../../src/mcp/sampling" +import { DEFAULT_CHUNK_TIMEOUT } from "../../src/provider/provider" +import { ModelCapability } from "../../src/provider/capability-registry" +import { wav } from "./wav-fixture" + + +function textRequest(text: string) { + return { messages: [{ role: "user" as const, content: { type: "text" as const, text } }], maxTokens: 100 } +} + +describe("decodedByteLength", () => { + test("matches Buffer for real payloads including both padding forms", () => { + for (const raw of ["a", "ab", "abc", "abcd", "hello world", "\u0000\u0001\u0002"]) { + const b64 = Buffer.from(raw).toString("base64") + expect(McpSampling.decodedByteLength(b64)).toBe(Buffer.byteLength(raw)) + } + }) + + test("rejects wrong length, illegal characters and interior padding", () => { + expect(McpSampling.decodedByteLength("abc")).toBeUndefined() + expect(McpSampling.decodedByteLength("!!!!")).toBeUndefined() + expect(McpSampling.decodedByteLength("ab=cQUJD")).toBeUndefined() + expect(McpSampling.decodedByteLength("AA AA")).toBeUndefined() + }) + + test("sizes a 30s 16kHz mono WAV without decoding it", () => { + const buffer = wav(30) + expect(buffer.length).toBe(44 + 30 * 16_000 * 2) + expect(McpSampling.decodedByteLength(buffer.toString("base64"))).toBe(buffer.length) + }) +}) + +describe("normalizeMime", () => { + test("lowercases, strips parameters and enforces the modality prefix", () => { + expect(McpSampling.normalizeMime("Audio/WAV", "audio")).toBe("audio/wav") + expect(McpSampling.normalizeMime("audio/wav; codecs=1", "audio")).toBe("audio/wav") + expect(McpSampling.normalizeMime("image/png", "audio")).toBeUndefined() + expect(McpSampling.normalizeMime("not-a-mime", "audio")).toBeUndefined() + expect(McpSampling.normalizeMime("audio/", "audio")).toBeUndefined() + }) +}) + +describe("convertMessages", () => { + test("text becomes a text part and a text requirement", () => { + const result = McpSampling.convertMessages(textRequest("hello")) + if (result instanceof McpSampling.SamplingError) throw result + expect(result.messages).toEqual([{ role: "user", content: [{ type: "text", text: "hello" }] }]) + expect(result.requirements).toEqual([{ modality: "text", bytes: 5 }]) + expect(result.summary.contentTypes).toEqual(["text"]) + }) + + test("image becomes a file part carrying raw bytes, never text", () => { + const data = Buffer.from("fakepng").toString("base64") + const result = McpSampling.convertMessages({ + messages: [{ role: "user", content: [{ type: "image", data, mimeType: "image/png" }] }], + maxTokens: 100, + }) + if (result instanceof McpSampling.SamplingError) throw result + expect(result.messages[0].content).toEqual([{ type: "file", data, mediaType: "image/png" }]) + expect(result.requirements).toEqual([{ modality: "image", mimeType: "image/png", bytes: 7 }]) + }) + + test("audio becomes a file part with its media type, and is summarised for the prompt", () => { + const buffer = wav(30) + const data = buffer.toString("base64") + const result = McpSampling.convertMessages({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Transcribe this." }, + { type: "audio", data, mimeType: "audio/wav" }, + ], + }, + ], + maxTokens: 2048, + systemPrompt: "You are a transcription engine.", + }) + if (result instanceof McpSampling.SamplingError) throw result + // The audio rides as real bytes with a media type — NOT stringified into text. + expect(result.messages[0].content).toEqual([ + { type: "text", text: "Transcribe this." }, + { type: "file", data, mediaType: "audio/wav" }, + ]) + expect(result.requirements).toContainEqual({ + modality: "audio", + mimeType: "audio/wav", + bytes: buffer.length, + }) + expect([...result.summary.contentTypes].sort()).toEqual(["audio", "text"]) + expect(result.summary.audio).toEqual([{ mimeType: "audio/wav", bytes: buffer.length }]) + expect(result.summary.systemPrompt).toBe("You are a transcription engine.") + expect(result.summary.textPrompt).toBe("Transcribe this.") + }) + + test("a bare (non-array) content block is accepted per the schema", () => { + const result = McpSampling.convertMessages(textRequest("bare")) + if (result instanceof McpSampling.SamplingError) throw result + expect(result.messages[0].content).toEqual([{ type: "text", text: "bare" }]) + }) + + test("illegal base64 is rejected with InvalidParams", () => { + const result = McpSampling.convertMessages({ + messages: [{ role: "user", content: [{ type: "audio", data: "not!!base64", mimeType: "audio/wav" }] }], + maxTokens: 10, + }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + expect(result.code).toBe(-32602) + expect(result.message).toMatch(/not valid base64/) + }) + + test("empty media payloads are rejected", () => { + const result = McpSampling.convertMessages({ + messages: [{ role: "user", content: [{ type: "audio", data: "", mimeType: "audio/wav" }] }], + maxTokens: 10, + }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + expect(result.message).toMatch(/empty/) + }) + + test("a wrong MIME for the declared modality is rejected", () => { + const data = Buffer.from("x").toString("base64") + const result = McpSampling.convertMessages({ + messages: [{ role: "user", content: [{ type: "audio", data, mimeType: "text/plain" }] }], + maxTokens: 10, + }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + expect(result.message).toMatch(/invalid audio mimeType/) + expect(result.data).toEqual({ mimeType: "text/plain" }) + }) + + test("an oversize systemPrompt is rejected before any model work", () => { + const result = McpSampling.convertMessages({ + ...textRequest("hi"), + systemPrompt: "x".repeat(ModelCapability.DEFAULT_MAX_TEXT_BYTES + 1), + }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + expect(result.message).toMatch(/systemPrompt exceeds/) + }) + + test("tools/toolChoice are refused because sampling.tools is not declared", () => { + for (const extra of [{ tools: [] }, { toolChoice: { mode: "auto" } }]) { + const result = McpSampling.convertMessages({ ...textRequest("hi"), ...extra }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + expect(result.message).toMatch(/does not declare sampling\.tools/) + } + }) + + test("structural problems are rejected", () => { + const cases: Array<[object, RegExp]> = [ + [{ messages: [], maxTokens: 10 }, /non-empty array/], + [{ ...textRequest("hi"), maxTokens: 0 }, /positive integer/], + [{ ...textRequest("hi"), maxTokens: 1.5 }, /positive integer/], + [{ ...textRequest("hi"), temperature: Number.NaN }, /finite number/], + [{ messages: [{ role: "system", content: { type: "text", text: "x" } }], maxTokens: 10 }, /unsupported message role/], + [{ messages: [{ role: "user", content: [] }], maxTokens: 10 }, /at least one content block/], + [ + { messages: [{ role: "user", content: [{ type: "video", data: "AAAA", mimeType: "video/mp4" }] }], maxTokens: 10 }, + /unsupported content type/, + ], + ] + for (const [params, pattern] of cases) { + const result = McpSampling.convertMessages(params as never) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error(`expected failure for ${JSON.stringify(params)}`) + expect(result.message).toMatch(pattern) + } + }) + + test("no provider credential can appear in a validation error", () => { + const result = McpSampling.convertMessages({ + messages: [{ role: "user", content: [{ type: "audio", data: "!!!!", mimeType: "audio/wav" }] }], + maxTokens: 10, + metadata: { apiKey: "sk-should-never-echo", baseURL: "https://evil.example" }, + }) + expect(result).toBeInstanceOf(McpSampling.SamplingError) + if (!(result instanceof McpSampling.SamplingError)) throw new Error("unreachable") + const serialized = JSON.stringify({ message: result.message, data: result.data }) + expect(serialized).not.toContain("sk-should-never-echo") + expect(serialized).not.toContain("evil.example") + }) +}) + +describe("policyFor", () => { + test("defaults to ask when unset, null, or an unknown value", () => { + expect(McpSampling.policyFor({}, "srv")).toBe("ask") + expect(McpSampling.policyFor({ mcp: {} }, "srv")).toBe("ask") + expect(McpSampling.policyFor({ mcp: { srv: {} } }, "srv")).toBe("ask") + // A nullable source arriving as null must NOT be read as "configured". + expect(McpSampling.policyFor({ mcp: { srv: { sampling: null as never } } }, "srv")).toBe("ask") + expect(McpSampling.policyFor({ mcp: { srv: { sampling: "nonsense" as never } } }, "srv")).toBe("ask") + }) + + test("honours each explicit per-server policy", () => { + expect(McpSampling.policyFor({ mcp: { srv: { sampling: "deny" } } }, "srv")).toBe("deny") + expect(McpSampling.policyFor({ mcp: { srv: { sampling: "allow" } } }, "srv")).toBe("allow") + expect(McpSampling.policyFor({ mcp: { srv: { sampling: "ask" } } }, "srv")).toBe("ask") + }) + + test("policy is per server, not global", () => { + const config = { mcp: { a: { sampling: "allow" as const }, b: { sampling: "deny" as const } } } + expect(McpSampling.policyFor(config, "a")).toBe("allow") + expect(McpSampling.policyFor(config, "b")).toBe("deny") + expect(McpSampling.policyFor(config, "c")).toBe("ask") + }) +}) + +describe("preview", () => { + test("collapses whitespace and truncates long prompts so logs stay bounded", () => { + expect(McpSampling.preview(undefined)).toBeUndefined() + expect(McpSampling.preview(" a\n\tb ")).toBe("a b") + const long = McpSampling.preview("y".repeat(500)) + expect(long).toHaveLength(201) + expect(long?.endsWith("…")).toBe(true) + }) +}) + +describe("SamplingError", () => { + test("maps onto a JSON-RPC error preserving code, message and data", () => { + const error = new McpSampling.SamplingError(-1, "nope", { server: "srv" }) + const mapped = error.toMcpError() + expect(mapped.code).toBe(-1) + expect(mapped.message).toContain("nope") + expect(mapped.data).toEqual({ server: "srv" }) + }) +}) + +/** + * THE SILENCE BOUND IS INHERITED, NOT INVENTED. + * + * Sampling used to carry `DEFAULT_SAMPLING_STALL_TIMEOUT = 45_000` for "the model + * produced nothing for this long". The repo already had that concept as the provider + * layer's `chunkTimeout` — same question, per-provider configurable, default 8 + * minutes — so two numbers disagreed 10x about one fact. These tests pin the + * resolution that removed the second number. + */ +describe("chunkTimeoutFor", () => { + test("falls back to the provider layer's own default rather than a sampling-owned number", () => { + expect(McpSampling.chunkTimeoutFor({}, "anyprovider")).toBe(DEFAULT_CHUNK_TIMEOUT) + expect(McpSampling.chunkTimeoutFor({ provider: {} }, "anyprovider")).toBe(DEFAULT_CHUNK_TIMEOUT) + expect(McpSampling.chunkTimeoutFor({ provider: { anyprovider: {} } }, "anyprovider")).toBe(DEFAULT_CHUNK_TIMEOUT) + expect(McpSampling.chunkTimeoutFor({ provider: { anyprovider: { options: {} } } }, "anyprovider")).toBe( + DEFAULT_CHUNK_TIMEOUT, + ) + // A different provider's setting must not leak across. + expect( + McpSampling.chunkTimeoutFor({ provider: { other: { options: { chunkTimeout: 1_234 } } } }, "anyprovider"), + ).toBe(DEFAULT_CHUNK_TIMEOUT) + }) + + test("honours the operator's per-provider chunkTimeout, the same key the main chat path reads", () => { + expect( + McpSampling.chunkTimeoutFor({ provider: { anyprovider: { options: { chunkTimeout: 60_000 } } } }, "anyprovider"), + ).toBe(60_000) + // 0 and negative pass THROUGH rather than falling back, because that is what + // they already mean to provider.ts: install no bound. `handle` reads them as + // "disabled"; turning them into the default here would silently re-enable a + // bound the operator switched off. + expect( + McpSampling.chunkTimeoutFor({ provider: { anyprovider: { options: { chunkTimeout: 0 } } } }, "anyprovider"), + ).toBe(0) + expect( + McpSampling.chunkTimeoutFor({ provider: { anyprovider: { options: { chunkTimeout: -1 } } } }, "anyprovider"), + ).toBe(-1) + }) + + test("treats a non-number as unconfigured, matching provider.ts's own typeof test", () => { + const bads: ReadonlyArray = ["not a number", null, undefined, true, {}, []] + for (const bad of bads) { + expect( + McpSampling.chunkTimeoutFor({ provider: { anyprovider: { options: { chunkTimeout: bad } } } }, "anyprovider"), + ).toBe(DEFAULT_CHUNK_TIMEOUT) + } + }) +}) diff --git a/packages/opencode/test/mcp/wav-fixture.ts b/packages/opencode/test/mcp/wav-fixture.ts new file mode 100644 index 000000000..115faecd6 --- /dev/null +++ b/packages/opencode/test/mcp/wav-fixture.ts @@ -0,0 +1,34 @@ +/** + * A real 16 kHz mono 16-bit PCM WAV, built here rather than committed as a binary + * fixture so the header stays inspectable. `seconds` scales the data chunk. + * + * Deliberately a PLAIN module, not a `.test.ts`. When this helper lived in + * `sampling.test.ts` and `sampling-e2e.test.ts` imported it from there, Bun + * attributed all 20 of that file's tests to the IMPORTER: the e2e file reported + * 36 tests (its own 16 plus the 20 it pulled in) and `sampling.test.ts` reported + * 0 of its own in any combined run. Every test still executed exactly once, but + * per-file totals were meaningless. A plain module keeps the attribution honest. + */ +export function wav(seconds: number) { + const sampleRate = 16_000 + const samples = Math.round(sampleRate * seconds) + const dataBytes = samples * 2 + const buffer = Buffer.alloc(44 + dataBytes) + buffer.write("RIFF", 0, "ascii") + buffer.writeUInt32LE(36 + dataBytes, 4) + buffer.write("WAVE", 8, "ascii") + buffer.write("fmt ", 12, "ascii") + buffer.writeUInt32LE(16, 16) + buffer.writeUInt16LE(1, 20) // PCM + buffer.writeUInt16LE(1, 22) // mono + buffer.writeUInt32LE(sampleRate, 24) + buffer.writeUInt32LE(sampleRate * 2, 28) // byte rate + buffer.writeUInt16LE(2, 32) // block align + buffer.writeUInt16LE(16, 34) // bits per sample + buffer.write("data", 36, "ascii") + buffer.writeUInt32LE(dataBytes, 40) + for (let i = 0; i < samples; i++) { + buffer.writeInt16LE(Math.round(8000 * Math.sin((2 * Math.PI * 440 * i) / sampleRate)), 44 + i * 2) + } + return buffer +} diff --git a/packages/opencode/test/provider/capability-registry-wire.test.ts b/packages/opencode/test/provider/capability-registry-wire.test.ts new file mode 100644 index 000000000..2b2bd0431 --- /dev/null +++ b/packages/opencode/test/provider/capability-registry-wire.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { createAnthropic } from "@ai-sdk/anthropic" +import { createGoogleGenerativeAI } from "@ai-sdk/google" +import { ModelCapability } from "../../src/provider/capability-registry" + +/** + * WIRE-LEVEL substantiation of the Model Capability Registry's audio verdicts. + * + * The registry is only trustworthy if "supported" and "unsupported" describe what + * the installed adapters actually do. Asserting the table against itself would be + * circular, so each case here drives the REAL adapter with an `audio/*` file part + * and checks the serialized request body (or the thrown rejection). An adapter + * upgrade that changes this behaviour fails here rather than silently making the + * registry lie. + */ + +const AUDIO = Buffer.from("RIFFfake").toString("base64") + +function json(body: unknown) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }) +} + +const OPENAI_REPLY = { + id: "1", + object: "chat.completion", + created: 1, + model: "m", + choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, +} + +const ANTHROPIC_REPLY = { + id: "m1", + type: "message", + role: "assistant", + model: "c", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, +} + +const GOOGLE_REPLY = { + candidates: [{ content: { parts: [{ text: "ok" }], role: "model" }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, +} + +/** Returns the serialized parts for an audio part, or the thrown error message. */ +async function sendAudio( + factory: "openai-compatible" | "anthropic" | "google", + mediaType: string, +): Promise<{ parts: unknown } | { error: string }> { + let captured: any + const capture = (reply: unknown) => + (async (_url: unknown, init: { body: string }) => { + captured = JSON.parse(init.body) + return json(reply) + }) as never + const prompt = [{ role: "user", content: [{ type: "file", data: AUDIO, mediaType }] }] + try { + if (factory === "openai-compatible") { + const provider = createOpenAICompatible({ + name: "t", + baseURL: "https://example.invalid/v1", + apiKey: "test-key", + fetch: capture(OPENAI_REPLY), + }) + await provider("m").doGenerate({ prompt } as never) + return { parts: captured.messages[0].content } + } + if (factory === "anthropic") { + const provider = createAnthropic({ apiKey: "test-key", fetch: capture(ANTHROPIC_REPLY) }) + await provider("claude-3-5-sonnet-20241022").doGenerate({ prompt } as never) + return { parts: captured.messages[0].content } + } + const provider = createGoogleGenerativeAI({ apiKey: "test-key", fetch: capture(GOOGLE_REPLY) }) + await provider("gemini-2.0-flash").doGenerate({ prompt } as never) + return { parts: captured.contents[0].parts } + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +describe("@ai-sdk/openai-compatible audio behaviour matches the registry", () => { + test.each(["audio/wav", "audio/mp3", "audio/mpeg"])("%s is serialized as input_audio", async (mediaType) => { + // The registry declares exactly this MIME set as supported. + expect(ModelCapability.adapterDeclaration("@ai-sdk/openai-compatible").audio.mimeTypes).toContain(mediaType) + const outcome = await sendAudio("openai-compatible", mediaType) + expect(outcome).not.toHaveProperty("error") + if ("error" in outcome) throw new Error("unreachable") + expect(outcome.parts).toEqual([{ type: "input_audio", input_audio: { data: AUDIO, format: expect.any(String) } }]) + }) + + test.each(["audio/flac", "audio/ogg"])("%s is refused by the adapter, so the registry excludes it", async (mediaType) => { + const declaration = ModelCapability.adapterDeclaration("@ai-sdk/openai-compatible").audio + expect(declaration.mimeTypes).not.toContain(mediaType) + const outcome = await sendAudio("openai-compatible", mediaType) + expect(outcome).toHaveProperty("error") + if (!("error" in outcome)) throw new Error("unreachable") + expect(outcome.error).toMatch(/not supported/) + }) +}) + +describe("@ai-sdk/anthropic audio is KNOWN-ABSENT", () => { + test("an audio/wav part is refused, which is why the registry says unsupported", async () => { + expect(ModelCapability.adapterDeclaration("@ai-sdk/anthropic").audio.support).toBe("unsupported") + const outcome = await sendAudio("anthropic", "audio/wav") + expect(outcome).toHaveProperty("error") + if (!("error" in outcome)) throw new Error("unreachable") + expect(outcome.error).toMatch(/audio\/wav/) + expect(outcome.error).toMatch(/not supported/) + }) +}) + +describe("@ai-sdk/google carries any audio MIME", () => { + test.each(["audio/wav", "audio/flac", "audio/ogg", "audio/mp3"])( + "%s passes through as inlineData, which is why the registry says any", + async (mediaType) => { + expect(ModelCapability.adapterDeclaration("@ai-sdk/google").audio.mimeTypes).toBe("any") + const outcome = await sendAudio("google", mediaType) + expect(outcome).not.toHaveProperty("error") + if ("error" in outcome) throw new Error("unreachable") + expect(outcome.parts).toEqual([{ inlineData: { mimeType: mediaType, data: AUDIO } }]) + }, + ) +}) diff --git a/packages/opencode/test/provider/capability-registry.test.ts b/packages/opencode/test/provider/capability-registry.test.ts new file mode 100644 index 000000000..8d9b05b02 --- /dev/null +++ b/packages/opencode/test/provider/capability-registry.test.ts @@ -0,0 +1,290 @@ +import { test, expect, describe } from "bun:test" +import { ModelCapability } from "../../src/provider/capability-registry" +import type { Provider } from "../../src/provider" + +// Minimal Provider.Model shaped fixture. Only the fields the registry reads are +// meaningful; the rest satisfy the type. +function model(input: { + id: string + providerID?: string + npm?: string + name?: string + text?: boolean + image?: boolean + audio?: boolean +}): Provider.Model { + return { + id: input.id, + providerID: input.providerID ?? "mimo", + name: input.name ?? input.id, + api: { npm: input.npm ?? "@ai-sdk/openai-compatible", id: input.id }, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { + text: input.text ?? true, + image: input.image ?? false, + audio: input.audio ?? false, + video: false, + pdf: false, + }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + limit: { context: 8000, output: 2000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + } as unknown as Provider.Model +} + +const AUDIO_WAV = { modality: "audio" as const, mimeType: "audio/wav", bytes: 960_000 } +const TEXT = { modality: "text" as const, bytes: 12 } + +describe("adapter declarations", () => { + test("google adapters declare audio supported for any audio MIME", () => { + for (const npm of ["@ai-sdk/google", "@ai-sdk/google-vertex"]) { + const declaration = ModelCapability.adapterDeclaration(npm) + expect(declaration.audio.support).toBe("supported") + expect(declaration.audio.mimeTypes).toBe("any") + } + }) + + test("openai-compatible declares audio supported only for wav/mp3/mpeg", () => { + const declaration = ModelCapability.adapterDeclaration("@ai-sdk/openai-compatible") + expect(declaration.audio.support).toBe("supported") + expect(declaration.audio.mimeTypes).toEqual(["audio/wav", "audio/mp3", "audio/mpeg"]) + }) + + test("anthropic and bedrock adapters declare audio KNOWN-ABSENT, not unknown", () => { + for (const npm of ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic", "@ai-sdk/amazon-bedrock"]) { + expect(ModelCapability.adapterDeclaration(npm).audio.support).toBe("unsupported") + } + }) + + test("an undeclared adapter reports audio as unknown rather than guessing", () => { + const declaration = ModelCapability.adapterDeclaration("@ai-sdk/some-future-provider") + expect(declaration.audio.support).toBe("unknown") + // Text and image stay usable so an unknown adapter is not locked out of + // ordinary sampling. + expect(declaration.text.support).toBe("supported") + expect(declaration.image.support).toBe("supported") + }) + + test("a missing npm falls back to the undeclared declaration", () => { + expect(ModelCapability.adapterDeclaration(undefined).audio.support).toBe("unknown") + }) +}) + +describe("model declaration ANDs the model gate with the adapter gate", () => { + test("adapter can carry audio but model does not declare it → unsupported", () => { + const subject = model({ id: "g", npm: "@ai-sdk/google", audio: false }) + expect(ModelCapability.modelDeclaration(subject, "audio").support).toBe("unsupported") + }) + + test("model declares audio and adapter can carry it → supported", () => { + const subject = model({ id: "g", npm: "@ai-sdk/google", audio: true }) + expect(ModelCapability.modelDeclaration(subject, "audio").support).toBe("supported") + }) + + test("model declares audio but adapter cannot carry it → unsupported", () => { + const subject = model({ id: "claude", npm: "@ai-sdk/anthropic", audio: true }) + expect(ModelCapability.modelDeclaration(subject, "audio").support).toBe("unsupported") + }) + + test("model declares audio but adapter support is unknown → unknown", () => { + const subject = model({ id: "future", npm: "@ai-sdk/unheard-of", audio: true }) + expect(ModelCapability.modelDeclaration(subject, "audio").support).toBe("unknown") + }) +}) + +describe("rejectionFor", () => { + test("accepts a 16kHz mono WAV on an audio-capable openai-compatible model", () => { + const subject = model({ id: "mimo-v2.5", audio: true }) + expect(ModelCapability.rejectionFor(subject, [TEXT, AUDIO_WAV])).toBeUndefined() + }) + + test("rejects an audio MIME the adapter does not accept", () => { + const subject = model({ id: "mimo-v2.5", audio: true }) + const reason = ModelCapability.rejectionFor(subject, [ + { modality: "audio", mimeType: "audio/flac", bytes: 1000 }, + ]) + expect(reason).toEqual({ kind: "mime-unsupported", modality: "audio", mimeType: "audio/flac" }) + }) + + test("rejects content over the declared byte cap", () => { + const subject = model({ id: "mimo-v2.5", audio: true }) + const bytes = ModelCapability.DEFAULT_MAX_MEDIA_BYTES + 1 + const reason = ModelCapability.rejectionFor(subject, [{ modality: "audio", mimeType: "audio/wav", bytes }]) + expect(reason).toEqual({ + kind: "too-large", + modality: "audio", + bytes, + maxBytes: ModelCapability.DEFAULT_MAX_MEDIA_BYTES, + }) + }) + + test("distinguishes known-absent from unknown in the rejection reason", () => { + const absent = model({ id: "claude", npm: "@ai-sdk/anthropic", audio: true }) + expect(ModelCapability.rejectionFor(absent, [AUDIO_WAV])).toEqual({ + kind: "modality-unsupported", + modality: "audio", + }) + const unproven = model({ id: "future", npm: "@ai-sdk/unheard-of", audio: true }) + expect(ModelCapability.rejectionFor(unproven, [AUDIO_WAV])).toEqual({ + kind: "modality-unknown", + modality: "audio", + }) + }) +}) + +describe("selectModel: filter then rank", () => { + test("audio content with only text-capable models configured returns a structured error", () => { + const models = [model({ id: "text-only-a" }), model({ id: "text-only-b" })] + const result = ModelCapability.selectModel({ models, requirements: [TEXT, AUDIO_WAV] }) + expect(result.ok).toBe(false) + if (result.ok) throw new Error("unreachable") + // Every configured model is accounted for, with a reason. + expect(result.rejections.map((item) => item.model).sort()).toEqual(["mimo/text-only-a", "mimo/text-only-b"]) + for (const rejection of result.rejections) { + expect(rejection.reason).toEqual({ kind: "modality-unsupported", modality: "audio" }) + } + expect(result.requirements).toEqual([TEXT, AUDIO_WAV]) + }) + + test("an exact hint on an eligible model wins", () => { + const models = [model({ id: "other", audio: true }), model({ id: "mimo-v2.5", audio: true })] + const result = ModelCapability.selectModel({ + models, + requirements: [AUDIO_WAV], + hints: [{ name: "mimo-v2.5" }], + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("mimo-v2.5") + expect(result.via).toBe("hint") + expect(result.hint).toBe("mimo-v2.5") + }) + + test("a hint naming a model that exists but lacks the modality does NOT win", () => { + // claude-x is configured and would match the hint by name, but cannot take + // audio. A hint must rank among eligible models, never widen eligibility. + const models = [ + model({ id: "claude-x", npm: "@ai-sdk/anthropic", audio: true }), + model({ id: "mimo-v2.5", audio: true }), + ] + const result = ModelCapability.selectModel({ + models, + requirements: [AUDIO_WAV], + hints: [{ name: "claude-x" }], + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("mimo-v2.5") + // The hint did not land, so selection fell through to the ordinary strategy. + expect(result.via).toBe("first-eligible") + }) + + test("a hint naming an unconfigured model falls through to the fallback", () => { + const fallback = model({ id: "mimo-v2.5", audio: true }) + const models = [model({ id: "zzz-other", audio: true }), fallback] + const result = ModelCapability.selectModel({ + models, + requirements: [AUDIO_WAV], + hints: [{ name: "gpt-not-configured" }], + fallback, + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("mimo-v2.5") + expect(result.via).toBe("fallback") + }) + + test("hints are consulted in server-preference order", () => { + const models = [model({ id: "first-choice", audio: true }), model({ id: "second-choice", audio: true })] + const result = ModelCapability.selectModel({ + models, + requirements: [AUDIO_WAV], + hints: [{ name: "second-choice" }, { name: "first-choice" }], + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("second-choice") + }) + + test("an ineligible fallback is not used; the first eligible model is", () => { + const fallback = model({ id: "claude-x", npm: "@ai-sdk/anthropic", audio: true }) + const models = [fallback, model({ id: "aaa-audio", audio: true })] + const result = ModelCapability.selectModel({ models, requirements: [AUDIO_WAV], fallback }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("aaa-audio") + expect(result.via).toBe("first-eligible") + }) + + test("a loose hint matches by substring but only among eligible models", () => { + const models = [model({ id: "mimo-v2.5-pro", audio: true })] + const result = ModelCapability.selectModel({ + models, + requirements: [AUDIO_WAV], + hints: [{ name: "mimo-v2.5" }], + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("mimo-v2.5-pro") + expect(result.via).toBe("hint") + }) + + test("no configured models at all is a structured error with no rejections", () => { + const result = ModelCapability.selectModel({ models: [], requirements: [TEXT] }) + expect(result.ok).toBe(false) + if (result.ok) throw new Error("unreachable") + expect(result.rejections).toEqual([]) + }) + + /** + * The `unknown` fail-closed path at the GATE, not just at the leaf. + * + * Measured: collapsing `unknown` into an eligible state (replacing + * `rejectionFor`'s unknown branch with `continue`) left the + * `adapterDeclaration` and `modelDeclaration` assertions above GREEN — they pin + * the declaration table, not the decision — and `selectModel`, the function the + * sampling handler actually calls, had no `unknown` case at all. Only + * `rejectionFor`'s reason-kind assertion failed. These two tests put the + * refusal itself under assertion. + */ + test("an unknown-support model is refused by the gate, distinctly from known-absent", () => { + const unproven = model({ id: "future", npm: "@ai-sdk/unheard-of", audio: true }) + const result = ModelCapability.selectModel({ models: [unproven], requirements: [TEXT, AUDIO_WAV] }) + expect(result.ok).toBe(false) + if (result.ok) throw new Error("unreachable") + expect(result.rejections).toEqual([ + { model: "mimo/future", reason: { kind: "modality-unknown", modality: "audio" } }, + ]) + // The operator-facing text separates "we do not know" from "this cannot + // work". These two lines pin wording only; they are not sensitive to a + // fail-open regression in the gate, which the assertions above cover. + expect(ModelCapability.describeRejection(result.rejections[0].reason)).toBe("has no declared audio support") + expect(ModelCapability.describeRejection({ kind: "modality-unsupported", modality: "audio" })).toBe( + "does not accept audio input", + ) + }) + + test("a hint cannot promote an unknown-support model over an eligible one", () => { + // The known-absent counterpart of this is asserted above; `unknown` needs its + // own case because it is a different branch. A hint ranks eligible models and + // must never widen eligibility to one whose support is merely unproven. + const models = [ + model({ id: "future", npm: "@ai-sdk/unheard-of", audio: true }), + model({ id: "mimo-v2.5", audio: true }), + ] + const result = ModelCapability.selectModel({ models, requirements: [AUDIO_WAV], hints: [{ name: "future" }] }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("unreachable") + expect(String(result.model.id)).toBe("mimo-v2.5") + expect(result.via).toBe("first-eligible") + }) +}) diff --git a/packages/opencode/test/skill/mimocode-docs.test.ts b/packages/opencode/test/skill/mimocode-docs.test.ts index 416d31dd1..16cada03a 100644 --- a/packages/opencode/test/skill/mimocode-docs.test.ts +++ b/packages/opencode/test/skill/mimocode-docs.test.ts @@ -57,3 +57,36 @@ describe("mimocode-docs provider guidance", () => { expect(providers).toContain("Never put the API key, base URL, display name, or combined `provider/model` string") }) }) + +/** + * These docs are fed to the model AS INSTRUCTIONS, so a misclassification here + * does not merely read wrong — it teaches the model that a valid config shape is + * invalid. `mcp_sampling` is glob-keyed by MCP server name (the handler passes + * `patterns: [server]`), and the page's own example uses the glob-map form, so + * listing it as action-only contradicted the example two paragraphs below it. + */ +describe("mimocode-docs permission arity", () => { + test("classifies mcp_sampling as glob-keyed, not action-only", async () => { + const permissions = await Bun.file(path.join(root, "reference/permissions.md")).text() + + const globLine = permissions.split("\n").find((line) => line.startsWith("Glob-keyed")) + const simpleLine = permissions.split("\n").find((line) => line.startsWith("Simple action-only")) + expect(globLine).toBeDefined() + expect(simpleLine).toBeDefined() + expect(globLine).toContain("`mcp_sampling`") + expect(simpleLine).not.toContain("`mcp_sampling`") + // The glob key is a server name, not a path or a command — say so, since the + // section is otherwise about paths and commands. + expect(permissions).toContain("except for `mcp_sampling`, where it is the MCP server name") + // The example that the old classification contradicted must still be present. + expect(permissions).toContain('"mcp_sampling": { "*": "ask", "mimo-cut": "allow" }') + }) + + test("states that a permission deny outranks mcp..sampling allow", async () => { + const permissions = await Bun.file(path.join(root, "reference/permissions.md")).text() + + expect(permissions).toContain( + 'A `deny` from either control wins: `mcp..sampling: "allow"` does **not** override `permission.mcp_sampling` denying that server.', + ) + }) +})