159 lines
4.5 KiB
TypeScript
159 lines
4.5 KiB
TypeScript
import {
|
|
type ReadResourceResult,
|
|
ReadResourceResultSchema,
|
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
import { z } from 'zod/v4'
|
|
import { ensureConnectedClient } from '../../services/mcp/client.js'
|
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
|
import { lazySchema } from '../../utils/lazySchema.js'
|
|
import {
|
|
getBinaryBlobSavedMessage,
|
|
persistBinaryContent,
|
|
} from '../../utils/mcpOutputStorage.js'
|
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
|
import { isOutputLineTruncated } from '../../utils/terminal.js'
|
|
import { DESCRIPTION, PROMPT } from './prompt.js'
|
|
import {
|
|
renderToolResultMessage,
|
|
renderToolUseMessage,
|
|
userFacingName,
|
|
} from './UI.js'
|
|
|
|
export const inputSchema = lazySchema(() =>
|
|
z.object({
|
|
server: z.string().describe('The MCP server name'),
|
|
uri: z.string().describe('The resource URI to read'),
|
|
}),
|
|
)
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
export const outputSchema = lazySchema(() =>
|
|
z.object({
|
|
contents: z.array(
|
|
z.object({
|
|
uri: z.string().describe('Resource URI'),
|
|
mimeType: z.string().optional().describe('MIME type of the content'),
|
|
text: z.string().optional().describe('Text content of the resource'),
|
|
blobSavedTo: z
|
|
.string()
|
|
.optional()
|
|
.describe('Path where binary blob content was saved'),
|
|
}),
|
|
),
|
|
}),
|
|
)
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
export const ReadMcpResourceTool = buildTool({
|
|
isConcurrencySafe() {
|
|
return true
|
|
},
|
|
isReadOnly() {
|
|
return true
|
|
},
|
|
toAutoClassifierInput(input) {
|
|
return `${input.server} ${input.uri}`
|
|
},
|
|
shouldDefer: true,
|
|
name: 'ReadMcpResourceTool',
|
|
searchHint: 'read a specific MCP resource by URI',
|
|
maxResultSizeChars: 100_000,
|
|
async description() {
|
|
return DESCRIPTION
|
|
},
|
|
async prompt() {
|
|
return PROMPT
|
|
},
|
|
get inputSchema(): InputSchema {
|
|
return inputSchema()
|
|
},
|
|
get outputSchema(): OutputSchema {
|
|
return outputSchema()
|
|
},
|
|
async call(input, { options: { mcpClients } }) {
|
|
const { server: serverName, uri } = input
|
|
|
|
const client = mcpClients.find(client => client.name === serverName)
|
|
|
|
if (!client) {
|
|
throw new Error(
|
|
`Server "${serverName}" not found. Available servers: ${mcpClients.map(c => c.name).join(', ')}`,
|
|
)
|
|
}
|
|
|
|
if (client.type !== 'connected') {
|
|
throw new Error(`Server "${serverName}" is not connected`)
|
|
}
|
|
|
|
if (!client.capabilities?.resources) {
|
|
throw new Error(`Server "${serverName}" does not support resources`)
|
|
}
|
|
|
|
const connectedClient = await ensureConnectedClient(client)
|
|
const result = (await connectedClient.client.request(
|
|
{
|
|
method: 'resources/read',
|
|
params: { uri },
|
|
},
|
|
ReadResourceResultSchema,
|
|
)) as ReadResourceResult
|
|
|
|
// Intercept any blob fields: decode, write raw bytes to disk with a
|
|
// mime-derived extension, and replace with a path. Otherwise the base64
|
|
// would be stringified straight into the context.
|
|
const contents = await Promise.all(
|
|
result.contents.map(async (c, i) => {
|
|
if ('text' in c) {
|
|
return { uri: c.uri, mimeType: c.mimeType, text: c.text }
|
|
}
|
|
if (!('blob' in c) || typeof c.blob !== 'string') {
|
|
return { uri: c.uri, mimeType: c.mimeType }
|
|
}
|
|
const persistId = `mcp-resource-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}`
|
|
const persisted = await persistBinaryContent(
|
|
Buffer.from(c.blob, 'base64'),
|
|
c.mimeType,
|
|
persistId,
|
|
)
|
|
if ('error' in persisted) {
|
|
return {
|
|
uri: c.uri,
|
|
mimeType: c.mimeType,
|
|
text: `Binary content could not be saved to disk: ${persisted.error}`,
|
|
}
|
|
}
|
|
return {
|
|
uri: c.uri,
|
|
mimeType: c.mimeType,
|
|
blobSavedTo: persisted.filepath,
|
|
text: getBinaryBlobSavedMessage(
|
|
persisted.filepath,
|
|
c.mimeType,
|
|
persisted.size,
|
|
`[Resource from ${serverName} at ${c.uri}] `,
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
|
|
return {
|
|
data: { contents },
|
|
}
|
|
},
|
|
renderToolUseMessage,
|
|
userFacingName,
|
|
renderToolResultMessage,
|
|
isResultTruncated(output: Output): boolean {
|
|
return isOutputLineTruncated(jsonStringify(output))
|
|
},
|
|
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: jsonStringify(content),
|
|
}
|
|
},
|
|
} satisfies ToolDef<InputSchema, Output>)
|