feat(opencode): code mode result+attachments envelope and typed describe

Expose mcp.defs() so code mode can read MCP outputSchema. Tool calls and
the final return now use one { result, attachments? } envelope; media
blocks become FilePart attachments. describe renders typed signatures
with the structured return type when an outputSchema is present.
This commit is contained in:
Aiden Cline
2026-06-30 09:27:39 -05:00
parent 3a6621c5fc
commit 14527d2047
6 changed files with 284 additions and 60 deletions
+19
View File
@@ -159,6 +159,12 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
/**
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
* mode uses to render tool signatures (including return types) to the model.
*/
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@@ -680,6 +686,18 @@ export const layer = Layer.effect(
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
const s = yield* InstanceState.get(state)
for (const [clientName, listed] of Object.entries(s.defs)) {
if (s.status[clientName]?.status !== "connected") continue
for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
}
}
return result
})
function collectFromConnected<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
@@ -982,6 +1000,7 @@ export const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,
+184 -47
View File
@@ -1,6 +1,7 @@
import { Tool } from "@/tool/tool"
import { EffectBridge } from "@/effect/bridge"
import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { Effect, Schema } from "effect"
export const CODE_MODE_TOOL = "execute"
@@ -16,6 +17,16 @@ type Metadata = {
error?: boolean
}
/**
* A model-facing attachment: the same shape used for both child tool results and
* the program's final `return`, and identical to a session `FilePart` (minus the
* ids), so it lowers 1:1 into `Tool.ExecuteResult.attachments`.
*/
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
/** The envelope every tool call resolves to, and the shape a program should `return`. */
export type Envelope = { result: unknown; attachments?: Attachment[] }
// `new Function`/`AsyncFunction` is not on the global scope, so reach it via the
// prototype of an async function literal. The body may use top-level `await` and `return`.
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as {
@@ -26,7 +37,15 @@ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const SEARCH = "search"
const DESCRIBE = "describe"
type CatalogEntry = { path: string; key: string; server: string; local: string; description: string; tool: AITool }
type CatalogEntry = {
path: string
key: string
server: string
local: string
description: string
tool: AITool
outputSchema?: JSONSchema7
}
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
const brief = (text: string | undefined, max = 120) => {
@@ -41,14 +60,20 @@ const toKey = (segments: readonly string[]) => segments.join("_").replaceAll("."
/**
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
* the sanitized MCP client names; the longest matching prefix wins so a server
* named `a_b` beats `a` for the key `a_b_tool`.
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
* definitions (keyed identically) so each entry retains its `outputSchema`.
*/
export function groupByServer(mcpTools: Record<string, AITool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
export function groupByServer(
mcpTools: Record<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server = byLongest.find((name) => key.startsWith(name + "_")) ?? key.slice(0, key.indexOf("_"))
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
@@ -56,6 +81,7 @@ export function groupByServer(mcpTools: Record<string, AITool>, servers: readonl
local,
description: mcpTools[key]!.description ?? "",
tool: mcpTools[key]!,
outputSchema: output,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
@@ -64,39 +90,61 @@ export function groupByServer(mcpTools: Record<string, AITool>, servers: readonl
const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
function jsonType(def: JSONSchema7 | boolean | undefined): string {
/**
* Render a JSON Schema as a compact TypeScript-ish type string for model-facing
* signatures. Depth-limited and total — never throws, falls back to `any`/`object`.
*/
export function renderType(def: JSONSchema7 | boolean | undefined, depth = 0): string {
if (!def || typeof def === "boolean") return "any"
if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ")
if (def.const !== undefined) return JSON.stringify(def.const)
if (Array.isArray(def.anyOf ?? def.oneOf)) {
const alts = (def.anyOf ?? def.oneOf)!
return alts.map((alt) => renderType(alt as JSONSchema7, depth)).join(" | ")
}
const type = Array.isArray(def.type) ? def.type[0] : def.type
switch (type) {
case "integer":
return "number"
case "array":
return "any[]"
case undefined:
return "any"
default:
case "string":
case "number":
case "boolean":
case "null":
return type
case "array": {
const items = Array.isArray(def.items) ? def.items[0] : def.items
return `${renderType(items as JSONSchema7 | undefined, depth + 1)}[]`
}
}
if (type === "object" || def.properties) {
if (depth >= 3) return "object"
const props = def.properties ?? {}
const required = new Set(Array.isArray(def.required) ? def.required : [])
const fields = Object.entries(props).map(
([name, value]) => `${name}${required.has(name) ? "" : "?"}: ${renderType(value as JSONSchema7, depth + 1)}`,
)
return fields.length > 0 ? `{ ${fields.join("; ")} }` : "object"
}
return "any"
}
function inputHint(tool: AITool): string {
function inputType(tool: AITool): string {
try {
const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined
const props = schema?.properties
if (!props || typeof props !== "object") return "input"
const required = new Set(Array.isArray(schema?.required) ? schema.required : [])
const fields = Object.entries(props).map(
([name, def]) => `${name}${required.has(name) ? "" : "?"}: ${jsonType(def as JSONSchema7)}`,
)
return fields.length > 0 ? `{ ${fields.join("; ")} }` : "{}"
if (!schema?.properties || typeof schema.properties !== "object") return "input"
return renderType(schema)
} catch {
return "input"
}
}
/** The return type the model sees for any tool: the structured `outputSchema` (when
* the MCP server declares one) wrapped in the result envelope, else `unknown`. */
const returnType = (outputSchema: JSONSchema7 | undefined) =>
`Promise<{ result: ${outputSchema ? renderType(outputSchema) : "unknown"}; attachments?: Attachment[] }>`
const signatureFor = (entry: CatalogEntry) =>
`tools${access(entry.server)}${access(entry.local)}(${inputHint(entry.tool)})`
`tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}`
/**
* The execute tool description: the calling convention, the discovery API, and a
@@ -109,10 +157,16 @@ export function describe(groups: Map<string, CatalogEntry[]>): string {
"",
"Discover tools inside your program, then call them:",
"- `await tools.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
"- `await tools.describe(path)` -> `{ path, description, signature, inputSchema }`",
"- Call a tool by its path: `await tools.<server>.<tool>(input)` or `await tools[path](input)`. Each returns a Promise.",
"- `await tools.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`",
"- Call a tool by its path: `await tools.<server>.<tool>(input)`. Each resolves to `{ result, attachments? }`.",
"",
"Compose multiple calls in one program and `return` the final value — intermediate results stay in the sandbox and never re-enter the conversation. Use `tools.search('', { namespace })` to list a namespace's tools.",
"Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.",
"`result` is the structured data; `attachments` carries images/files for the user. Return a whole tool",
"result to forward its attachments, or return only its `.result` to drop the media. You cannot read the",
"contents of an attachment in code — only pass it along.",
"",
"Compose multiple calls in one program and `return` the final value — intermediate results stay in the",
"sandbox and never re-enter the conversation. Use `tools.search('', { namespace })` to list a namespace.",
]
if (groups.size === 0) {
lines.push("", "No MCP servers are currently connected.")
@@ -125,24 +179,74 @@ export function describe(groups: Map<string, CatalogEntry[]>): string {
return lines.join("\n")
}
const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return segment.length > 0 ? segment : undefined
}
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
/**
* Reduce an MCP tool result to the value the program should see: structured
* content when present, otherwise the joined text blocks, otherwise the raw
* result.
* Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result`
* is the structured content (or joined text); media blocks (image/audio/resource)
* become attachments. Lenient — never throws on unexpected shapes.
*/
export function toolResultValue(result: unknown): unknown {
if (result === null || typeof result !== "object") return result
export function toEnvelope(result: unknown): Envelope {
if (result === null || typeof result !== "object") return { result }
const record = result as { structuredContent?: unknown; content?: unknown }
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
if (Array.isArray(record.content)) {
const text = record.content
.filter((item): item is { type: "text"; text: string } => item?.type === "text" && typeof item.text === "string")
.map((item) => item.text)
.join("\n")
if (text.length > 0) return text
return record.content
const attachments: Attachment[] = []
const text: string[] = []
const content = Array.isArray(record.content) ? record.content : []
for (const item of content) {
if (!item || typeof item !== "object") continue
const block = item as Record<string, unknown>
switch (block.type) {
case "text":
if (typeof block.text === "string") text.push(block.text)
break
case "image":
case "audio":
if (typeof block.data === "string" && typeof block.mimeType === "string") {
attachments.push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
}
break
case "resource": {
const res = block.resource as Record<string, unknown> | undefined
if (res && typeof res === "object") {
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
const uri = typeof res.uri === "string" ? res.uri : undefined
if (typeof res.blob === "string") {
attachments.push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
} else if (typeof res.text === "string") {
text.push(res.text)
}
}
break
}
case "resource_link":
if (typeof block.uri === "string") {
attachments.push({
type: "file",
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
url: block.uri,
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
})
}
break
}
}
return result
const value =
record.structuredContent !== undefined && record.structuredContent !== null
? record.structuredContent
: text.length > 0
? text.join("\n")
: content.length > 0
? undefined // media-only result
: result
return attachments.length > 0 ? { result: value, attachments } : { result: value }
}
/** Coerce the program's return value to model-facing text without ever failing on shape. */
@@ -156,6 +260,28 @@ export function formatValue(value: unknown): string {
}
}
const isAttachment = (value: unknown): value is Attachment => {
if (!value || typeof value !== "object") return false
const a = value as Record<string, unknown>
return a.type === "file" && typeof a.mime === "string" && typeof a.url === "string"
}
/**
* Lower the program's return value into model-facing output + attachments. The
* value is treated as a `{ result, attachments? }` envelope when it has a `result`
* key; otherwise the whole value is the result. Attachments are model-curated.
*/
export function fromReturn(value: unknown): { output: string; attachments?: Attachment[] } {
if (value !== null && typeof value === "object" && "result" in value) {
const env = value as { result: unknown; attachments?: unknown }
const attachments = Array.isArray(env.attachments) ? env.attachments.filter(isAttachment) : []
return attachments.length > 0
? { output: formatValue(env.result), attachments }
: { output: formatValue(env.result) }
}
return { output: formatValue(value) }
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
@@ -166,8 +292,12 @@ function errorMessage(error: unknown): string {
}
}
export function define(mcpTools: Record<string, AITool>, servers: readonly string[]) {
const groups = groupByServer(mcpTools, servers)
export function define(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
) {
const groups = groupByServer(mcpTools, servers, mcpDefs)
const catalog: CatalogEntry[] = [...groups.values()].flat()
const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const))
@@ -202,7 +332,13 @@ export function define(mcpTools: Record<string, AITool>, servers: readonly strin
} catch {
inputSchema = undefined
}
return { path: entry.path, description: entry.description, signature: signatureFor(entry), inputSchema }
return {
path: entry.path,
description: entry.description,
signature: signatureFor(entry),
inputSchema,
...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}),
}
}
return Tool.define(
@@ -228,7 +364,7 @@ export function define(mcpTools: Record<string, AITool>, servers: readonly strin
}),
),
)
return toolResultValue(result)
return toEnvelope(result)
})
// Recursive path-accumulating proxy: `tools.<server>.<tool>(args)` and
@@ -261,14 +397,15 @@ export function define(mcpTools: Record<string, AITool>, servers: readonly strin
try: () => new AsyncFunction("tools", params.code)(tools),
catch: (error) => error,
}).pipe(
Effect.map(
(value) =>
({
title: "Code mode",
metadata: { toolCalls: calls },
output: formatValue(value),
}) satisfies Tool.ExecuteResult<Metadata>,
),
Effect.map((value) => {
const { output, attachments } = fromReturn(value)
return {
title: "Code mode",
metadata: { toolCalls: calls },
output,
...(attachments && attachments.length > 0 ? { attachments } : {}),
} satisfies Tool.ExecuteResult<Metadata>
}),
Effect.catch((error) =>
Effect.succeed({
title: "Code mode",
+5 -1
View File
@@ -99,7 +99,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const codeModeTool =
flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
? yield* Tool.init(
yield* CodeModeTool.define(mcpTools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)),
yield* CodeModeTool.define(
mcpTools,
yield* mcp.defs(),
Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize),
),
)
: undefined
const registryTools = yield* registry.tools({