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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user