feat(core): add mcp support (#34513)

This commit is contained in:
Aiden Cline 2026-06-30 08:37:44 -05:00 committed by GitHub
commit b1ca070b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1966 additions and 388 deletions

View file

@ -16,6 +16,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
@ -37,6 +38,7 @@ import { Snapshot } from "./snapshot"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SystemContextRegistry } from "./system-context/registry"
import { BuiltInTools } from "./tool/builtins"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { ToolOutputStore } from "./tool-output-store"
@ -68,6 +70,7 @@ export const locationServices = LayerNode.group([
SystemContextBuiltIns.node,
LocationMutation.node,
FileMutation.node,
MCP.node,
PermissionV2.node,
ToolOutputStore.node,
ToolRegistry.node,
@ -80,6 +83,7 @@ export const locationServices = LayerNode.group([
Generate.node,
ReadToolFileSystem.node,
BuiltInTools.node,
McpTool.node,
SessionRunnerModel.node,
SessionCompaction.node,
Snapshot.node,

View file

@ -0,0 +1,289 @@
export * as MCPClient from "./client"
import path from "node:path"
import { execFile } from "node:child_process"
import { pathToFileURL } from "node:url"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_REQUEST_TIMEOUT = 30_000
type Transport = StdioClientTransport | StreamableHTTPClientTransport
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {}
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
server: Schema.String,
message: Schema.String,
}) {}
export interface ToolDefinition {
readonly name: string
readonly description: string | undefined
readonly inputSchema: unknown
}
export type CallToolContent =
| { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
export interface CallToolResult {
readonly isError: boolean
readonly structured: unknown
readonly content: ReadonlyArray<CallToolContent>
}
export interface LogMessage {
readonly level: LoggingMessageNotification["params"]["level"]
readonly logger?: LoggingMessageNotification["params"]["logger"]
readonly data: LoggingMessageNotification["params"]["data"]
}
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
export interface Connection {
/** Server-supplied usage instructions from the initialize result, if any. */
readonly instructions: string | undefined
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
readonly onLog: (callback: (message: LogMessage) => void) => void
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
readonly onToolsChanged: (callback: () => void) => void
}
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
export const connect = Effect.fnUntraced(function* (
server: string,
config: typeof ConfigMCP.Server.Type,
directory: string,
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
authProvider?: OAuthClientProvider,
) {
const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
return new StdioClientTransport({
command,
args,
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
stderr: "pipe",
env: {
...(process.env as Record<string, string>),
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...config.environment,
},
})
}
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
})
})
const client = new Client(
{ name: "opencode", version: InstallationVersion },
{
capabilities: {
// https://github.com/anomalyco/opencode/issues/2308
roots: {},
},
},
)
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
const exit = yield* Effect.tryPromise({
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
catch: (error) => error,
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
yield* Effect.addFinalizer(() =>
cleanupStdioDescendants(transport).pipe(
Effect.andThen(Effect.promise(() => client.close())),
Effect.ignore,
),
)
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
return {
instructions: client.getInstructions()?.trim() || undefined,
tools: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.tools) return []
const tools = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout: requestTimeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: requestTimeout,
})
}
},
(result) => result.tools,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
)
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}))
}),
callTool: (input) =>
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
CallToolResultSchema,
// The SDK only sends a progress token when onprogress is present, which enables timeout resets.
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.map((result) => ({
isError: result.isError === true,
structured: result.structuredContent,
content: result.content.flatMap((part): CallToolContent[] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "image" || part.type === "audio")
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
if (part.type === "resource") {
const resource = part.resource
if ("text" in resource && typeof resource.text === "string")
return [{ type: "text", text: resource.text }]
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
return [{ type: "text", text: resource.uri }]
}
return []
}),
})),
),
onClose: (callback) => {
client.onclose = callback
},
onLog: (callback) => {
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
},
onToolsChanged: (callback) => {
if (!client.getServerCapabilities()?.tools?.listChanged) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
},
} satisfies Connection
}
yield* cleanupStdioDescendants(transport).pipe(
Effect.andThen(Effect.promise(() => transport.close())),
Effect.ignore,
)
const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
})
// SDK close stops the MCP process, but not child processes it spawned.
const cleanupStdioDescendants = (transport: Transport) =>
Effect.gen(function* () {
if (!(transport instanceof StdioClientTransport)) return
const pid = transport.pid
if (typeof pid !== "number") return
yield* Effect.forEach(
yield* descendantPids(pid),
(pid) =>
Effect.try({
try: () => process.kill(pid, "SIGTERM"),
catch: () => undefined,
}).pipe(Effect.ignore),
{ discard: true },
)
})
const descendantPids = Effect.fnUntraced(function* (root: number) {
if (process.platform === "win32") return []
const result: number[] = []
const queue = [root]
for (let index = 0; index < queue.length; index++) {
const parent = queue[index]
if (parent === undefined) return result
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
result.push(...children)
queue.push(...children)
}
return result
})
const childPids = (pid: number) =>
Effect.promise(
() =>
new Promise<number[]>((resolve) => {
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
resolve(
stdout
.split("\n")
.map((line) => Number.parseInt(line, 10))
.filter((pid) => Number.isInteger(pid)),
)
})
}),
)
async function paginate<R extends { nextCursor?: string }, T>(
list: (cursor: string | undefined) => Promise<R>,
items: (result: R) => T[],
) {
const collected: T[] = []
const seen = new Set<string>()
let cursor: string | undefined
while (true) {
const result = await list(cursor)
collected.push(...items(result))
if (result.nextCursor === undefined) return collected
// A repeating cursor never terminates; bail instead of hanging the connection forever.
if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`)
seen.add(result.nextCursor)
cursor = result.nextCursor
}
}
const isOutputSchemaError = (error: Error) =>
/can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)

View file

@ -0,0 +1,78 @@
export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { McpTool } from "../tool/mcp"
import { MCP } from "./index"
import { SystemContext } from "../system-context/index"
const Summary = Schema.Struct({
server: Schema.String,
instructions: Schema.String,
})
type Summary = typeof Summary.Type
const render = (servers: ReadonlyArray<Summary>) =>
[
"<mcp_instructions>",
...servers.flatMap((server) => [
` <server name="${server.server}">`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
]),
"</mcp_instructions>",
].join("\n")
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
// Hide a server only when every tool it contributes is wholly denied for this agent.
const visible = instructions
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return (
owned.length === 0 ||
owned.some(
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
if (visible.length === 0) return SystemContext.empty
return SystemContext.make({
key: SystemContext.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible),
baseline: render,
update: (_previous, current) =>
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
render(current),
].join("\n"),
removed: () => "MCP server instructions are no longer available.",
})
}),
})
}),
)
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })

View file

@ -0,0 +1,498 @@
export * as MCP from "./index"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { createHash } from "node:crypto"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Config } from "../config"
import { ConfigMCP } from "../config/mcp"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration"
import { IntegrationConnection } from "../integration/connection"
import { Location } from "../location"
import { MCPClient } from "./client"
import { MCPOAuth } from "./oauth"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export type ServerName = typeof ServerName.Type
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
export const Status = Mcp.Status
export type Status = Mcp.Status
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
name: ServerName,
status: Status,
integrationID: Integration.ID.pipe(Schema.optional),
connection: IntegrationConnection.Info.pipe(Schema.optional),
}) {}
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
server: ServerName,
instructions: Schema.String,
}) {}
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
server: ServerName,
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
inputSchema: Schema.Unknown.pipe(Schema.optional),
}) {}
export const ToolResultContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }),
]).pipe(Schema.toTaggedUnion("type"))
export type ToolResultContent = typeof ToolResultContent.Type
export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({
server: ServerName,
tool: Schema.String,
isError: Schema.Boolean,
structured: Schema.Unknown.pipe(Schema.optional),
content: Schema.Array(ToolResultContent),
}) {}
export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
required: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({
server: ServerName,
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
arguments: Schema.Array(PromptArgument).pipe(Schema.optional),
}) {}
export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({
role: Schema.String,
content: Schema.Unknown,
}) {}
export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({
server: ServerName,
name: Schema.String,
messages: Schema.Array(PromptMessage),
}) {}
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
server: ServerName,
name: Schema.String,
uri: Schema.String,
description: Schema.String.pipe(Schema.optional),
mimeType: Schema.String.pipe(Schema.optional),
}) {}
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
server: ServerName,
name: Schema.String,
uriTemplate: Schema.String,
description: Schema.String.pipe(Schema.optional),
mimeType: Schema.String.pipe(Schema.optional),
}) {}
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
resources: Schema.Array(Resource),
templates: Schema.Array(ResourceTemplate),
}) {}
export const ResourceContentPart = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
uri: Schema.String,
text: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
Schema.Struct({
type: Schema.Literal("blob"),
uri: Schema.String,
blob: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
]).pipe(Schema.toTaggedUnion("type"))
export type ResourceContentPart = typeof ResourceContentPart.Type
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
server: ServerName,
uri: Schema.String,
contents: Schema.Array(ResourceContentPart),
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName,
}) {}
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
server: ServerName,
tool: Schema.String,
message: Schema.String,
}) {}
type ServerEntry = {
readonly config: typeof ConfigMCP.Server.Type
status: Status
readonly startup: Deferred.Deferred<void>
scope?: Scope.Closeable
client?: MCPClient.Connection
tools?: ReadonlyArray<Tool>
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
integrationID?: Integration.ID
}
export interface Interface {
readonly servers: () => Effect.Effect<ServerInfo[]>
readonly tools: () => Effect.Effect<Tool[]>
readonly callTool: (input: {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
readonly prompt: (input: {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
readonly readResource: (input: {
readonly server: ServerName | string
readonly uri: string
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const location = yield* Location.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const root = yield* Scope.make()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
// Global MCP timeout defaults, later config files overriding earlier ones.
const timeout = Object.assign(
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
// Later config files win for duplicate server names; per-server timeout overrides globals.
const runtime = new Map<ServerName, ServerEntry>()
for (const entry of documents) {
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" },
startup: Deferred.makeUnsafe<void>(),
})
}
}
// Register every remote server as an OAuth integration so credentials live in the global store
// rather than in committed config. Servers that connect anonymously simply never use the method.
const registrations: Array<{
readonly name: ServerName
readonly remote: typeof ConfigMCP.Remote.Type
readonly integrationID: Integration.ID
readonly methodID: Integration.MethodID
}> = []
for (const [name, entry] of runtime) {
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
const remote = entry.config
// Key identity on name + url, not url alone: two configs for the same url under different names are
// distinct logical servers that may hold different accounts, so they must not share a credential row.
const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
entry.integrationID = Integration.ID.make(suffix)
registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
}
if (registrations.length > 0)
yield* integration.transform((draft) => {
for (const reg of registrations) {
draft.update(reg.integrationID, (ref) => {
ref.name = reg.name
})
draft.method.update({
integrationID: reg.integrationID,
method: { id: reg.methodID, type: "oauth", label: reg.name },
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
})
}
})
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
const name = ServerName.make(server)
const entry = runtime.get(name)
if (!entry) return yield* new NotFoundError({ server: name })
return { name, entry }
})
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
new ServerInfo({
name,
status: entry.status,
integrationID: entry.integrationID,
connection,
})
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
scope: oauth?.scope,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
let current: Credential.OAuth | undefined = found.value
return MCPOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
current = undefined
await Effect.runPromise(credentials.remove(credentialID))
},
store: {
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
saveTokens: async (tokens) => {
current = MCPOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
})
await Effect.runPromise(credentials.update(credentialID, { value: current }))
},
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
},
})
})
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, def))
}),
)
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
connection.onClose(() => {
entry.client = undefined
entry.tools = undefined
entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
})
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
connection.onToolsChanged(() => {
fork(
refreshTools(name, entry, connection).pipe(
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
Effect.ignore,
),
)
})
}
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
return Effect.logDebug("MCP server log", fields)
case "info":
case "notice":
return Effect.logInfo("MCP server log", fields)
case "warning":
return Effect.logWarning("MCP server log", fields)
case "error":
case "critical":
case "alert":
case "emergency":
return Effect.logError("MCP server log", fields)
}
}
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
Scope.provide(scope),
Effect.exit,
)
if (Exit.isSuccess(result)) {
entry.client = result.value.connection
entry.tools = result.value.defs.map((def) => toTool(name, def))
entry.status = { status: "connected" }
watch(name, entry, result.value.connection)
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
// after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
yield* Scope.close(scope, Exit.void)
entry.scope = undefined
const error = Cause.squash(result.cause)
entry.status =
error instanceof MCPClient.NeedsAuthError
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
// Disabled servers settle their startup immediately so queries never block on them.
for (const [name, entry] of runtime) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
continue
}
fork(startServer(name, entry))
}
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
const owned = new Set(registrations.map((reg) => reg.integrationID))
const reconnect = (integrationID: Integration.ID) =>
Effect.gen(function* () {
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
if (!match) return
const [name, entry] = match
if (entry.config.disabled) return
if (entry.scope) {
yield* Scope.close(entry.scope, Exit.void)
entry.scope = undefined
entry.client = undefined
entry.tools = undefined
}
yield* startServer(name, entry)
})
fork(
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
),
)
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
concurrency: "unbounded",
discard: true,
})
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
const target = yield* requireServer(server)
yield* Deferred.await(target.entry.startup)
})
return Service.of({
servers: Effect.fn("MCP.servers")(function* () {
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
return yield* Effect.forEach(entries, ([name, entry]) =>
Effect.gen(function* () {
const connection = entry.integrationID
? yield* integration.connection.active(entry.integrationID)
: undefined
return info(name, entry, connection)
}),
)
}),
tools: Effect.fn("MCP.tools")(function* () {
yield* whenAllReady
return Array.from(runtime.values())
.flatMap((entry) => entry.tools ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
callTool: Effect.fn("MCP.callTool")(function* (input) {
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
if (!target.entry.client)
return yield* new ToolCallError({
server: target.name,
tool: input.name,
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args })
.pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message })))
return new ToolResult({
server: target.name,
tool: input.name,
isError: result.isError,
structured: result.structured,
content: result.content,
})
}),
instructions: Effect.fn("MCP.instructions")(function* () {
yield* whenAllReady
return Array.from(runtime)
.flatMap(([server, entry]) => {
const instructions = entry.client?.instructions
if (!instructions) return []
return [new ServerInstructions({ server, instructions })]
})
.toSorted((a, b) => a.server.localeCompare(b.server))
}),
prompts: Effect.fn("MCP.prompts")(function* () {
yield* whenAllReady
return []
}),
prompt: Effect.fn("MCP.prompt")(function* (input) {
yield* gate(input.server)
return undefined
}),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady
return new ResourceCatalog({ resources: [], templates: [] })
}),
readResource: Effect.fn("MCP.readResource")(function* (input) {
yield* gate(input.server)
return undefined
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
})

View file

@ -0,0 +1,238 @@
export * as MCPOAuth from "./oauth"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "../config/mcp"
import { OauthCallbackPage } from "../oauth/page"
import type { Integration } from "../integration"
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
export interface Store {
readonly tokens: () => Promise<OAuthTokens | undefined>
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
readonly codeVerifier: () => Promise<string | undefined>
readonly saveCodeVerifier: (verifier: string) => Promise<void>
}
export interface Options {
/** Loopback URL the authorization server redirects back to after the user approves. */
readonly redirectUrl: string
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
readonly scope?: string
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
* The caller is responsible for validating the value echoed back to the redirect. */
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
readonly onRedirect: (url: URL) => void | Promise<void>
readonly store: Store
}
/**
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
*/
export const provider = (options: Options): OAuthClientProvider => {
const state = options.state
const client = options.client
return {
redirectUrl: options.redirectUrl,
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
client_uri: "https://opencode.ai",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
...(options.scope ? { scope: options.scope } : {}),
},
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
// provider has no redirect to validate, so it omits it.
...(state !== undefined ? { state: () => state } : {}),
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
clientInformation: () =>
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
saveClientInformation: (info) => options.store.saveClientInformation(info),
tokens: () => options.store.tokens(),
saveTokens: (tokens) => options.store.saveTokens(tokens),
redirectToAuthorization: (url) => options.onRedirect(url),
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
codeVerifier: async () => {
const verifier = await options.store.codeVerifier()
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
return verifier
},
}
}
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
export const memoryStore = (): Store => {
let tokens: OAuthTokens | undefined
let client: OAuthClientInformationMixed | undefined
let verifier: string | undefined
return {
tokens: async () => tokens,
saveTokens: async (value) => {
tokens = value
},
clientInformation: async () => client,
saveClientInformation: async (value) => {
client = value
},
codeVerifier: async () => verifier,
saveCodeVerifier: async (value) => {
verifier = value
},
}
}
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
export const clientFromCredential = (credential: Credential.OAuth) =>
credential.metadata?.client as OAuthClientInformationMixed | undefined
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
export const toCredential = (input: {
readonly methodID: Integration.MethodID
readonly serverUrl: string
readonly tokens: OAuthTokens
readonly client: OAuthClientInformationMixed | undefined
}) =>
Credential.OAuth.make({
type: "oauth",
methodID: input.methodID,
access: input.tokens.access_token,
refresh: input.tokens.refresh_token ?? "",
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
metadata: {
serverUrl: input.serverUrl,
tokenType: input.tokens.token_type,
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
...(input.client ? { client: input.client } : {}),
},
})
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
const metadata = credential.metadata ?? {}
return {
access_token: credential.access,
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
}
}
/**
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
*/
export const authorize = (input: {
readonly name: string
readonly config: typeof ConfigMCP.Remote.Type
readonly methodID: Integration.MethodID
}) =>
Effect.gen(function* () {
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== redirectPath) {
response.writeHead(404).end("Not found")
return
}
const fail = (reason: string) => {
Effect.runFork(Deferred.fail(code, new Error(reason)))
response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error)
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
// state parameter exists for, so an attacker can't inject their own authorization code.
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code")
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined
const port = yield* Effect.callback<number, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {
const address = server.address()
resume(
address && typeof address === "object"
? Effect.succeed(address.port)
: Effect.fail(new Error("Could not determine MCP OAuth callback port")),
)
})
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
onRedirect: (url) => {
authorizationUrl = url
},
store,
})
const finalize = Effect.gen(function* () {
const tokens = yield* Effect.promise(() => store.tokens())
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
const client = yield* Effect.promise(() => store.clientInformation())
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
const result = yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
if (result === "AUTHORIZED") {
return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
}
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
return {
url: authorizationUrl.toString(),
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
mode: "auto" as const,
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
),
}
})

View file

@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
@ -102,6 +103,7 @@ export const layer = Layer.effect(
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const mcpGuidance = yield* McpGuidance.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@ -160,7 +162,7 @@ export const layer = Layer.effect(
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
concurrency: "unbounded",
}).pipe(Effect.map(SystemContext.combine))
@ -424,6 +426,7 @@ export const node = makeLocationNode({
SystemContextRegistry.node,
SkillGuidance.node,
ReferenceGuidance.node,
McpGuidance.node,
SessionCompaction.node,
Snapshot.node,
Database.node,

View file

@ -0,0 +1,101 @@
export * as McpTool from "./mcp"
import { createHash } from "node:crypto"
import { ToolFailure } from "@opencode-ai/llm"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { MCP } from "../mcp"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
const MAX_NAME_LENGTH = 64
const HASH_LENGTH = 8
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
/**
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
* and hashed down when it would exceed the 64-char limit.
*/
export const name = (server: string, tool: string) => {
const joined = sanitize(server) + "_" + sanitize(tool)
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
}
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) =>
content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim()
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tools.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
Tool.make({
description: tool.description ?? "",
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
}),
})
// Register the current tool set under a fresh child scope, then close the previous one so the
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const used = new Set<string>()
const record: Record<string, Tool.AnyTool> = {}
for (const tool of yield* mcp.tools()) {
const initial = name(tool.server, tool.name)
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
used.add(key)
record[key] = make(tool.server, tool)
}
const next = yield* Scope.fork(scope)
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
yield* reconcile.pipe(Effect.forkScoped)
yield* events
.subscribe(McpEvent.ToolsChanged)
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
}),
)
export const node = makeLocationNode({
name: "mcp-tools",
layer,
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node],
})

View file

@ -60,6 +60,23 @@ type Config<
}) => ReadonlyArray<Content>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
}
/**
* Config for a tool whose input shape is a raw JSON Schema not known at compile
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content.
*/
type DynamicConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure>
}
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ToolDefinition
@ -72,6 +89,17 @@ export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
export function make(config: DynamicConfig): AnyTool
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
if ("jsonSchema" in config) return makeDynamic(config)
return makeTyped(config)
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
const definitions = new Map<string, ToolDefinition>()
@ -113,16 +141,8 @@ export function make<
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
config.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
})),
),
),
@ -131,6 +151,35 @@ export function make<
return tool
}
function makeDynamic(config: DynamicConfig): AnyTool {
const tool = Object.freeze({}) as AnyTool
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = new ToolDefinition({
name,
description: config.description,
inputSchema: config.jsonSchema,
outputSchema: config.outputSchema,
})
definitions.set(name, definition)
return definition
},
settle: (call, context) =>
config
.execute(call.input, context)
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
})
return tool
}
function toModelContent(part: Content) {
if (part.type === "text") return { type: "text" as const, text: part.text }
return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void

View file

@ -29,7 +29,19 @@ const keys = new Set([
export function isV1(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
return Object.keys(input).some((key) => keys.has(key))
const record = input as Record<string, unknown>
if (Object.keys(record).some((key) => keys.has(key))) return true
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
const mcp = record.mcp
return (
typeof mcp === "object" &&
mcp !== null &&
!Array.isArray(mcp) &&
!("servers" in mcp) &&
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
)
}
export function migrate(info: typeof ConfigV1.Info.Type) {