/ tools / ReadMcpResourceTool / ReadMcpResourceTool.ts
ReadMcpResourceTool.ts
  1  import {
  2    type ReadResourceResult,
  3    ReadResourceResultSchema,
  4  } from '@modelcontextprotocol/sdk/types.js'
  5  import { z } from 'zod/v4'
  6  import { ensureConnectedClient } from '../../services/mcp/client.js'
  7  import { buildTool, type ToolDef } from '../../Tool.js'
  8  import { lazySchema } from '../../utils/lazySchema.js'
  9  import {
 10    getBinaryBlobSavedMessage,
 11    persistBinaryContent,
 12  } from '../../utils/mcpOutputStorage.js'
 13  import { jsonStringify } from '../../utils/slowOperations.js'
 14  import { isOutputLineTruncated } from '../../utils/terminal.js'
 15  import { DESCRIPTION, PROMPT } from './prompt.js'
 16  import {
 17    renderToolResultMessage,
 18    renderToolUseMessage,
 19    userFacingName,
 20  } from './UI.js'
 21  
 22  export const inputSchema = lazySchema(() =>
 23    z.object({
 24      server: z.string().describe('The MCP server name'),
 25      uri: z.string().describe('The resource URI to read'),
 26    }),
 27  )
 28  type InputSchema = ReturnType<typeof inputSchema>
 29  
 30  export const outputSchema = lazySchema(() =>
 31    z.object({
 32      contents: z.array(
 33        z.object({
 34          uri: z.string().describe('Resource URI'),
 35          mimeType: z.string().optional().describe('MIME type of the content'),
 36          text: z.string().optional().describe('Text content of the resource'),
 37          blobSavedTo: z
 38            .string()
 39            .optional()
 40            .describe('Path where binary blob content was saved'),
 41        }),
 42      ),
 43    }),
 44  )
 45  type OutputSchema = ReturnType<typeof outputSchema>
 46  
 47  export type Output = z.infer<OutputSchema>
 48  
 49  export const ReadMcpResourceTool = buildTool({
 50    isConcurrencySafe() {
 51      return true
 52    },
 53    isReadOnly() {
 54      return true
 55    },
 56    toAutoClassifierInput(input) {
 57      return `${input.server} ${input.uri}`
 58    },
 59    shouldDefer: true,
 60    name: 'ReadMcpResourceTool',
 61    searchHint: 'read a specific MCP resource by URI',
 62    maxResultSizeChars: 100_000,
 63    async description() {
 64      return DESCRIPTION
 65    },
 66    async prompt() {
 67      return PROMPT
 68    },
 69    get inputSchema(): InputSchema {
 70      return inputSchema()
 71    },
 72    get outputSchema(): OutputSchema {
 73      return outputSchema()
 74    },
 75    async call(input, { options: { mcpClients } }) {
 76      const { server: serverName, uri } = input
 77  
 78      const client = mcpClients.find(client => client.name === serverName)
 79  
 80      if (!client) {
 81        throw new Error(
 82          `Server "${serverName}" not found. Available servers: ${mcpClients.map(c => c.name).join(', ')}`,
 83        )
 84      }
 85  
 86      if (client.type !== 'connected') {
 87        throw new Error(`Server "${serverName}" is not connected`)
 88      }
 89  
 90      if (!client.capabilities?.resources) {
 91        throw new Error(`Server "${serverName}" does not support resources`)
 92      }
 93  
 94      const connectedClient = await ensureConnectedClient(client)
 95      const result = (await connectedClient.client.request(
 96        {
 97          method: 'resources/read',
 98          params: { uri },
 99        },
100        ReadResourceResultSchema,
101      )) as ReadResourceResult
102  
103      // Intercept any blob fields: decode, write raw bytes to disk with a
104      // mime-derived extension, and replace with a path. Otherwise the base64
105      // would be stringified straight into the context.
106      const contents = await Promise.all(
107        result.contents.map(async (c, i) => {
108          if ('text' in c) {
109            return { uri: c.uri, mimeType: c.mimeType, text: c.text }
110          }
111          if (!('blob' in c) || typeof c.blob !== 'string') {
112            return { uri: c.uri, mimeType: c.mimeType }
113          }
114          const persistId = `mcp-resource-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}`
115          const persisted = await persistBinaryContent(
116            Buffer.from(c.blob, 'base64'),
117            c.mimeType,
118            persistId,
119          )
120          if ('error' in persisted) {
121            return {
122              uri: c.uri,
123              mimeType: c.mimeType,
124              text: `Binary content could not be saved to disk: ${persisted.error}`,
125            }
126          }
127          return {
128            uri: c.uri,
129            mimeType: c.mimeType,
130            blobSavedTo: persisted.filepath,
131            text: getBinaryBlobSavedMessage(
132              persisted.filepath,
133              c.mimeType,
134              persisted.size,
135              `[Resource from ${serverName} at ${c.uri}] `,
136            ),
137          }
138        }),
139      )
140  
141      return {
142        data: { contents },
143      }
144    },
145    renderToolUseMessage,
146    userFacingName,
147    renderToolResultMessage,
148    isResultTruncated(output: Output): boolean {
149      return isOutputLineTruncated(jsonStringify(output))
150    },
151    mapToolResultToToolResultBlockParam(content, toolUseID) {
152      return {
153        tool_use_id: toolUseID,
154        type: 'tool_result',
155        content: jsonStringify(content),
156      }
157    },
158  } satisfies ToolDef<InputSchema, Output>)