feat(mcp): add resource read tools (#33483)
This commit is contained in:
@@ -86,6 +86,7 @@ export function fetch<T extends { name: string }>(
|
|||||||
client: Client,
|
client: Client,
|
||||||
list: (client: Client) => Promise<T[]>,
|
list: (client: Client) => Promise<T[]>,
|
||||||
label: string,
|
label: string,
|
||||||
|
key?: (item: T) => string,
|
||||||
) {
|
) {
|
||||||
return Effect.tryPromise({
|
return Effect.tryPromise({
|
||||||
try: () => list(client),
|
try: () => list(client),
|
||||||
@@ -100,7 +101,10 @@ export function fetch<T extends { name: string }>(
|
|||||||
Effect.map((items) => {
|
Effect.map((items) => {
|
||||||
const sanitizedClient = sanitize(clientName)
|
const sanitizedClient = sanitize(clientName)
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
items.map((item) => [sanitizedClient + ":" + sanitize(item.name), { ...item, client: clientName }]),
|
items.map((item) => [
|
||||||
|
key ? clientName + ":" + key(item) : sanitizedClient + ":" + sanitize(item.name),
|
||||||
|
{ ...item, client: clientName },
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
Effect.orElseSucceed(() => undefined),
|
Effect.orElseSucceed(() => undefined),
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export interface Interface {
|
|||||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||||
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||||
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||||
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
|
||||||
@@ -654,17 +654,22 @@ export const layer = Layer.effect(
|
|||||||
s: State,
|
s: State,
|
||||||
listFn: (c: Client, timeout?: number) => Promise<T[]>,
|
listFn: (c: Client, timeout?: number) => Promise<T[]>,
|
||||||
label: string,
|
label: string,
|
||||||
|
key?: (item: T) => string,
|
||||||
|
targetClientName?: string,
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
return yield* Effect.forEach(
|
return yield* Effect.forEach(
|
||||||
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
|
Object.entries(s.clients).filter(
|
||||||
|
([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName),
|
||||||
|
),
|
||||||
([clientName, client]) =>
|
([clientName, client]) =>
|
||||||
McpCatalog.fetch(
|
McpCatalog.fetch(
|
||||||
clientName,
|
clientName,
|
||||||
client,
|
client,
|
||||||
(c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
|
(c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
|
||||||
label,
|
label,
|
||||||
|
key,
|
||||||
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
||||||
@@ -675,8 +680,14 @@ export const layer = Layer.effect(
|
|||||||
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts")
|
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.prompts, "prompts")
|
||||||
})
|
})
|
||||||
|
|
||||||
const resources = Effect.fn("MCP.resources")(function* () {
|
const resources = Effect.fn("MCP.resources")(function* (clientName?: string) {
|
||||||
return yield* collectFromConnected(yield* InstanceState.get(state), McpCatalog.resources, "resources")
|
return yield* collectFromConnected(
|
||||||
|
yield* InstanceState.get(state),
|
||||||
|
McpCatalog.resources,
|
||||||
|
"resources",
|
||||||
|
(resource) => resource.uri,
|
||||||
|
clientName,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const withClient = Effect.fnUntraced(function* <A>(
|
const withClient = Effect.fnUntraced(function* <A>(
|
||||||
|
|||||||
@@ -214,9 +214,10 @@ export function merge(...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule[]
|
|||||||
|
|
||||||
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
|
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
|
||||||
const edits = ["edit", "write", "apply_patch"]
|
const edits = ["edit", "write", "apply_patch"]
|
||||||
|
const reads = ["list_mcp_resources", "read_mcp_resource"]
|
||||||
return new Set(
|
return new Set(
|
||||||
tools.filter((tool) => {
|
tools.filter((tool) => {
|
||||||
const permission = edits.includes(tool) ? "edit" : tool
|
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
return rule?.pattern === "*" && rule.action === "deny"
|
return rule?.pattern === "*" && rule.action === "deny"
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
|||||||
|
|
||||||
const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
|
const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
|
||||||
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
|
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
|
||||||
|
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
||||||
|
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
||||||
|
"application/pdf",
|
||||||
|
"image/gif",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
])
|
||||||
|
|
||||||
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
||||||
|
|
||||||
@@ -77,6 +85,18 @@ IMPORTANT:
|
|||||||
|
|
||||||
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
|
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
|
||||||
|
|
||||||
|
function mcpResourceBase64Size(value: string) {
|
||||||
|
const trimmed = value.replace(/\s/g, "")
|
||||||
|
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
||||||
|
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMcpResourceBytes(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`
|
||||||
|
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
|
||||||
|
return `${Math.ceil(value / (1024 * 1024))} MB`
|
||||||
|
}
|
||||||
|
|
||||||
function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
|
function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
|
||||||
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
|
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
|
||||||
// They are not pending work and must not trigger an assistant-prefill request.
|
// They are not pending work and must not trigger an assistant-prefill request.
|
||||||
@@ -731,7 +751,8 @@ export const layer = Layer.effect(
|
|||||||
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
|
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
|
||||||
const items = Array.isArray(content.contents) ? content.contents : [content.contents]
|
const items = Array.isArray(content.contents) ? content.contents : [content.contents]
|
||||||
for (const c of items) {
|
for (const c of items) {
|
||||||
if ("text" in c && c.text) {
|
if (!c || typeof c !== "object") continue
|
||||||
|
if ("text" in c && typeof c.text === "string" && c.text) {
|
||||||
pieces.push({
|
pieces.push({
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -739,18 +760,47 @@ export const layer = Layer.effect(
|
|||||||
synthetic: true,
|
synthetic: true,
|
||||||
text: c.text,
|
text: c.text,
|
||||||
})
|
})
|
||||||
} else if ("blob" in c && c.blob) {
|
} else if ("blob" in c && typeof c.blob === "string" && c.blob) {
|
||||||
const mime = "mimeType" in c ? c.mimeType : part.mime
|
const mime = "mimeType" in c && typeof c.mimeType === "string" ? c.mimeType : part.mime
|
||||||
|
const filename = "uri" in c && typeof c.uri === "string" ? c.uri : part.filename
|
||||||
|
const size = mcpResourceBase64Size(c.blob)
|
||||||
|
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||||
pieces.push({
|
pieces.push({
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
text: `[Binary content: ${mime}]`,
|
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) is not a supported attachment type]`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||||
|
pieces.push({
|
||||||
|
messageID: info.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text: `[Binary MCP resource omitted: ${filename ?? uri} (${mime}, ${formatMcpResourceBytes(size)}) exceeds ${formatMcpResourceBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pieces.push({
|
||||||
|
messageID: info.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text: `[Binary MCP resource attached: ${filename ?? uri} (${mime})]`,
|
||||||
|
})
|
||||||
|
pieces.push({
|
||||||
|
messageID: info.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "file",
|
||||||
|
mime,
|
||||||
|
filename,
|
||||||
|
url: `data:${mime};base64,${c.blob}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
|
||||||
} else {
|
} else {
|
||||||
const error = Cause.squash(exit.cause)
|
const error = Cause.squash(exit.cause)
|
||||||
yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
|
yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ import { PartID } from "./schema"
|
|||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
|
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
|
||||||
|
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
|
||||||
|
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
|
||||||
|
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
|
||||||
|
"application/pdf",
|
||||||
|
"image/gif",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
])
|
||||||
|
|
||||||
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
@@ -114,6 +126,175 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
|
||||||
|
(client) => !!client.getServerCapabilities()?.resources,
|
||||||
|
)
|
||||||
|
if (hasMcpResourceServer) {
|
||||||
|
tools[LIST_MCP_RESOURCES_TOOL] = tool({
|
||||||
|
description:
|
||||||
|
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
|
||||||
|
inputSchema: jsonSchema(
|
||||||
|
ProviderTransform.schema(input.model, {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
execute(args, opts) {
|
||||||
|
return run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const parsed = parseListMcpResourcesArgs(args)
|
||||||
|
const ctx = context(toRecord(args), opts)
|
||||||
|
const clients = yield* mcp.clients()
|
||||||
|
const resourceServers = Object.entries(clients)
|
||||||
|
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
|
||||||
|
.map((entry) => entry[0])
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
if (parsed.server && !resourceServers.includes(parsed.server)) {
|
||||||
|
throw new Error(
|
||||||
|
resourceServers.length === 0
|
||||||
|
? `MCP server "${parsed.server}" does not support resources`
|
||||||
|
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const permissionPatterns = parsed.server
|
||||||
|
? [`mcp:${parsed.server}:*`]
|
||||||
|
: resourceServers.map((server) => `mcp:${server}:*`)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "read",
|
||||||
|
metadata: parsed.server ? { server: parsed.server } : {},
|
||||||
|
patterns: permissionPatterns,
|
||||||
|
always: permissionPatterns,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resources = Object.values(yield* mcp.resources(parsed.server))
|
||||||
|
const filtered = resources
|
||||||
|
.filter((resource) => !parsed.server || resource.client === parsed.server)
|
||||||
|
.toSorted((a, b) =>
|
||||||
|
(a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
|
||||||
|
b.client + "\u0000" + b.name + "\u0000" + b.uri,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
|
||||||
|
const truncated = yield* truncate.output(content, {}, input.agent)
|
||||||
|
const output = {
|
||||||
|
title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
|
||||||
|
metadata: {
|
||||||
|
count: filtered.length,
|
||||||
|
servers: resourceServers,
|
||||||
|
...(parsed.server ? { server: parsed.server } : {}),
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
},
|
||||||
|
output: truncated.content,
|
||||||
|
}
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
tools[READ_MCP_RESOURCE_TOOL] = tool({
|
||||||
|
description:
|
||||||
|
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
|
||||||
|
inputSchema: jsonSchema(
|
||||||
|
ProviderTransform.schema(input.model, {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server: {
|
||||||
|
type: "string",
|
||||||
|
description: "MCP server name exactly as returned by list_mcp_resources.",
|
||||||
|
},
|
||||||
|
uri: {
|
||||||
|
type: "string",
|
||||||
|
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["server", "uri"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
execute(args, opts) {
|
||||||
|
return run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const parsed = parseReadMcpResourceArgs(args)
|
||||||
|
const ctx = context(toRecord(args), opts)
|
||||||
|
const clients = yield* mcp.clients()
|
||||||
|
const client = clients[parsed.server]
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`MCP server "${parsed.server}" is not connected`)
|
||||||
|
}
|
||||||
|
if (!client.getServerCapabilities()?.resources) {
|
||||||
|
throw new Error(`MCP server "${parsed.server}" does not support resources`)
|
||||||
|
}
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "read",
|
||||||
|
metadata: { server: parsed.server, uri: parsed.uri },
|
||||||
|
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
|
||||||
|
always: [`mcp:${parsed.server}:*`],
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = yield* mcp.readResource(parsed.server, parsed.uri)
|
||||||
|
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
|
||||||
|
|
||||||
|
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
|
||||||
|
const truncated = yield* truncate.output(formatted.text, {}, input.agent)
|
||||||
|
const output = {
|
||||||
|
title: `MCP resource: ${parsed.uri}`,
|
||||||
|
metadata: {
|
||||||
|
server: parsed.server,
|
||||||
|
uri: parsed.uri,
|
||||||
|
contents: formatted.contents,
|
||||||
|
attachments: formatted.attachments.length,
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
},
|
||||||
|
output: truncated.content,
|
||||||
|
attachments: formatted.attachments.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||||
const execute = item.execute
|
const execute = item.execute
|
||||||
if (!execute) continue
|
if (!execute) continue
|
||||||
@@ -163,10 +344,24 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
const { resource } = contentItem
|
const { resource } = contentItem
|
||||||
if (resource.text) textParts.push(resource.text)
|
if (resource.text) textParts.push(resource.text)
|
||||||
if (resource.blob) {
|
if (resource.blob) {
|
||||||
|
const mime = resource.mimeType ?? "application/octet-stream"
|
||||||
|
const size = base64Size(resource.blob)
|
||||||
|
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||||
|
textParts.push(
|
||||||
|
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||||
|
textParts.push(
|
||||||
|
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
attachments.push({
|
attachments.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: resource.mimeType ?? "application/octet-stream",
|
mime,
|
||||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
url: `data:${mime};base64,${resource.blob}`,
|
||||||
filename: resource.uri,
|
filename: resource.uri,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -204,4 +399,94 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
return tools
|
return tools
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toRecord(value: unknown) {
|
||||||
|
if (isRecord(value)) return value
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListMcpResourcesArgs(value: unknown) {
|
||||||
|
const args = toRecord(value)
|
||||||
|
return { server: optionalString(args, "server") }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReadMcpResourceArgs(value: unknown) {
|
||||||
|
const args = toRecord(value)
|
||||||
|
return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(args: Record<string, unknown>, key: string) {
|
||||||
|
const value = args[key]
|
||||||
|
if (value === undefined || value === null || value === "") return undefined
|
||||||
|
if (typeof value !== "string") throw new Error(`${key} must be a string`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(args: Record<string, unknown>, key: string) {
|
||||||
|
const value = optionalString(args, key)
|
||||||
|
if (value) return value
|
||||||
|
throw new Error(`${key} is required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMcpResource(resource: MCP.Resource) {
|
||||||
|
const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
|
||||||
|
return { ...result, server: resource.client }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
|
||||||
|
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
|
||||||
|
const text: string[] = []
|
||||||
|
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemUri = typeof item.uri === "string" ? item.uri : uri
|
||||||
|
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
|
||||||
|
if (typeof item.text === "string") {
|
||||||
|
text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof item.blob === "string") {
|
||||||
|
const size = base64Size(item.blob)
|
||||||
|
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
|
||||||
|
text.push(
|
||||||
|
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
|
||||||
|
text.push(
|
||||||
|
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime,
|
||||||
|
url: `data:${mime};base64,${item.blob}`,
|
||||||
|
filename: itemUri,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text.push(`[MCP resource content without text or blob: ${itemUri}]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents: items.length,
|
||||||
|
attachments,
|
||||||
|
text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Size(value: string) {
|
||||||
|
const trimmed = value.replace(/\s/g, "")
|
||||||
|
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
|
||||||
|
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`
|
||||||
|
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
|
||||||
|
return `${Math.ceil(value / (1024 * 1024))} MB`
|
||||||
|
}
|
||||||
|
|
||||||
export * as SessionTools from "./tools"
|
export * as SessionTools from "./tools"
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ it.instance(
|
|||||||
|
|
||||||
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
|
||||||
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
|
||||||
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"])
|
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
|
||||||
expect(serverState.listToolsCalls).toBe(2)
|
expect(serverState.listToolsCalls).toBe(2)
|
||||||
expect(serverState.listPromptsCalls).toBe(2)
|
expect(serverState.listPromptsCalls).toBe(2)
|
||||||
expect(serverState.listResourcesCalls).toBe(2)
|
expect(serverState.listResourcesCalls).toBe(2)
|
||||||
@@ -796,7 +796,10 @@ it.instance(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
lastCreatedClientName = "resource-server"
|
lastCreatedClientName = "resource-server"
|
||||||
const serverState = getOrCreateClientState("resource-server")
|
const serverState = getOrCreateClientState("resource-server")
|
||||||
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
|
serverState.resources = [
|
||||||
|
{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" },
|
||||||
|
{ name: "my-resource", uri: "ui://component-state", description: "A second resource with same name" },
|
||||||
|
]
|
||||||
|
|
||||||
yield* mcp.add("resource-server", {
|
yield* mcp.add("resource-server", {
|
||||||
type: "local",
|
type: "local",
|
||||||
@@ -804,10 +807,10 @@ it.instance(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resources = yield* mcp.resources()
|
const resources = yield* mcp.resources()
|
||||||
expect(Object.keys(resources).length).toBe(1)
|
expect(Object.keys(resources)).toEqual([
|
||||||
const key = Object.keys(resources)[0]
|
"resource-server:file:///test.txt",
|
||||||
expect(key).toContain("resource-server")
|
"resource-server:ui://component-state",
|
||||||
expect(key).toContain("my-resource")
|
])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -863,7 +866,7 @@ it.instance(
|
|||||||
expect(statusName(result.status, "resource-only-server")).toBe("connected")
|
expect(statusName(result.status, "resource-only-server")).toBe("connected")
|
||||||
expect(serverState.listToolsCalls).toBe(0)
|
expect(serverState.listToolsCalls).toBe(0)
|
||||||
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
|
expect(Object.keys(yield* mcp.tools())).toHaveLength(0)
|
||||||
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs"])
|
expect(Object.keys(yield* mcp.resources())).toEqual(["resource-only-server:docs://readme"])
|
||||||
expect(serverState.listResourcesCalls).toBe(1)
|
expect(serverState.listResourcesCalls).toBe(1)
|
||||||
expect(serverState.listPromptsCalls).toBe(0)
|
expect(serverState.listPromptsCalls).toBe(0)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ mcpTest.instance(
|
|||||||
const result = yield* mcp.authenticate("test-oauth-resources")
|
const result = yield* mcp.authenticate("test-oauth-resources")
|
||||||
expect(result.status).toBe("connected")
|
expect(result.status).toBe("connected")
|
||||||
expect(listToolsCalls).toBe(0)
|
expect(listToolsCalls).toBe(0)
|
||||||
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs"])
|
expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ config: config("test-oauth-resources") },
|
{ config: config("test-oauth-resources") },
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ export function Autocomplete(props: {
|
|||||||
</text>
|
</text>
|
||||||
<Show when={option().description}>
|
<Show when={option().description}>
|
||||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
||||||
{option().description}
|
{" " + option().description?.trimStart()}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Reference in New Issue
Block a user