|
| 1 | +import type { |
| 2 | + BlobResourceContents, |
| 3 | + ReadResourceResult, |
| 4 | + TextResourceContents, |
| 5 | +} from '@modelcontextprotocol/sdk/types.js'; |
| 6 | + |
| 7 | +import type { ApifyClient } from '../apify_client.js'; |
| 8 | +import { getApifyAPIBaseUrl } from '../apify_client.js'; |
| 9 | +import { KV_RECORD_MAX_INLINE_BYTES } from '../const.js'; |
| 10 | +import { getHttpStatusCode } from '../utils/logging.js'; |
| 11 | + |
| 12 | +const JSON_MIME_TYPE = 'application/json'; |
| 13 | +const TEXT_MIME_TYPE = 'text/plain'; |
| 14 | + |
| 15 | +/** |
| 16 | + * True when the URI is an Apify API URL (same origin as the configured API base). |
| 17 | + * |
| 18 | + * This is the security gate for the generic read proxy: the apify-client attaches the |
| 19 | + * session token as an `Authorization` header to every outbound request, so we must only |
| 20 | + * hand it Apify API URLs — never an arbitrary host. |
| 21 | + */ |
| 22 | +export function isApifyApiUri(uri: string): boolean { |
| 23 | + try { |
| 24 | + return new URL(uri).origin === new URL(getApifyAPIBaseUrl()).origin; |
| 25 | + } catch { |
| 26 | + return false; |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +/** Matches an Apify key-value-store record path, capturing the store id and the record key. */ |
| 31 | +const KV_RECORD_PATH_RE = /^\/v2\/key-value-stores\/([^/]+)\/records\/(.+)$/; |
| 32 | + |
| 33 | +/** |
| 34 | + * Download URL for a binary too large to inline. For a key-value-store record URI, returns the |
| 35 | + * store's signed `recordPublicUrl` — fetchable without an API token when the client can read the |
| 36 | + * store's URL signing key. Falls back to the original API URL for any other endpoint, or if minting |
| 37 | + * the signed URL fails (fetching that link then needs a token). |
| 38 | + */ |
| 39 | +async function getRecordDownloadUrl(uri: string, apifyClient: ApifyClient): Promise<string> { |
| 40 | + let pathname: string; |
| 41 | + try { |
| 42 | + pathname = new URL(uri).pathname; |
| 43 | + } catch { |
| 44 | + return uri; |
| 45 | + } |
| 46 | + const match = KV_RECORD_PATH_RE.exec(pathname); |
| 47 | + if (!match) return uri; |
| 48 | + try { |
| 49 | + const store = apifyClient.keyValueStore(decodeURIComponent(match[1])); |
| 50 | + return await store.getRecordPublicUrl(decodeURIComponent(match[2])); |
| 51 | + } catch { |
| 52 | + return uri; |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +/** Single explanatory text-contents result for a not-found / no-token / refused read. */ |
| 57 | +function buildTextResult(uri: string, text: string): ReadResourceResult { |
| 58 | + return { contents: [{ uri, mimeType: TEXT_MIME_TYPE, text } satisfies TextResourceContents] }; |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Read any Apify API GET endpoint as an MCP resource. |
| 63 | + * |
| 64 | + * A thin proxy: the apify-client injects the session token (and MCP-origin / payment headers), |
| 65 | + * performs the GET, and parses the body by Content-Type — JSON to an object, text/xml to a |
| 66 | + * string, anything else to a Buffer, an empty body to `undefined`. We branch on that resulting |
| 67 | + * JS type, not the MIME type. Errors (a missing resource, a bad token, a 5xx) never throw; they |
| 68 | + * return an explanatory text block, matching the resources/read soft-fail contract. |
| 69 | + */ |
| 70 | +export async function readApiResource(uri: string, apifyClient?: ApifyClient): Promise<ReadResourceResult> { |
| 71 | + if (!apifyClient) { |
| 72 | + return buildTextResult(uri, `Cannot read ${uri}: no Apify token in this session.`); |
| 73 | + } |
| 74 | + if (!isApifyApiUri(uri)) { |
| 75 | + return buildTextResult( |
| 76 | + uri, |
| 77 | + `Cannot read ${uri}: only Apify API URLs (${getApifyAPIBaseUrl()}) are readable as resources.`, |
| 78 | + ); |
| 79 | + } |
| 80 | + |
| 81 | + let response: { data: unknown; headers: Record<string, unknown> }; |
| 82 | + try { |
| 83 | + // Default responseType is `arraybuffer`, which lets the client's parse interceptor decode |
| 84 | + // the body by Content-Type. Do NOT set `forceBuffer` — that would keep everything as raw bytes. |
| 85 | + response = await apifyClient.httpClient.call({ url: uri, method: 'GET', responseType: 'arraybuffer' }); |
| 86 | + } catch (err) { |
| 87 | + const status = getHttpStatusCode(err); |
| 88 | + const message = err instanceof Error ? err.message : String(err); |
| 89 | + return buildTextResult(uri, `Failed to read ${uri}: ${status ? `HTTP ${status}: ` : ''}${message}`); |
| 90 | + } |
| 91 | + |
| 92 | + const contentTypeHeader = response.headers['content-type']; |
| 93 | + const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined; |
| 94 | + const { data } = response; |
| 95 | + |
| 96 | + // An empty body (e.g. an Actor that wrote an empty OUTPUT) is legitimate; emit empty text. |
| 97 | + if (data === undefined || data === null) { |
| 98 | + return buildTextResult(uri, ''); |
| 99 | + } |
| 100 | + |
| 101 | + if (Buffer.isBuffer(data)) { |
| 102 | + const mimeType = contentType?.split(';')[0].trim().toLowerCase(); |
| 103 | + // Inlining a large binary as base64 would blow up the client's context, so above the inline |
| 104 | + // limit link out instead: an explanatory text block with the URL, size, and type (resources/read |
| 105 | + // has no resource_link content type), matching the soft-fail contract. For a key-value-store record |
| 106 | + // the link is the signed public URL, fetchable without a token; other endpoints fall back to the |
| 107 | + // (token-gated) API URL. |
| 108 | + if (data.length > KV_RECORD_MAX_INLINE_BYTES) { |
| 109 | + const downloadUrl = await getRecordDownloadUrl(uri, apifyClient); |
| 110 | + return buildTextResult( |
| 111 | + uri, |
| 112 | + `Content (${mimeType ?? 'binary'}, ${data.length} bytes) is too large to inline. ` + |
| 113 | + `Fetch it directly from: ${downloadUrl}`, |
| 114 | + ); |
| 115 | + } |
| 116 | + return { |
| 117 | + contents: [ |
| 118 | + { uri, ...(mimeType && { mimeType }), blob: data.toString('base64') } satisfies BlobResourceContents, |
| 119 | + ], |
| 120 | + }; |
| 121 | + } |
| 122 | + |
| 123 | + // JSON (already parsed to an object/array) or text/xml (a string). A string is emitted verbatim |
| 124 | + // with its declared Content-Type; anything else is lossless-serialized as JSON. |
| 125 | + const text = typeof data === 'string' ? data : JSON.stringify(data); |
| 126 | + return { |
| 127 | + contents: [ |
| 128 | + { |
| 129 | + uri, |
| 130 | + mimeType: contentType ?? (typeof data === 'string' ? TEXT_MIME_TYPE : JSON_MIME_TYPE), |
| 131 | + text, |
| 132 | + } satisfies TextResourceContents, |
| 133 | + ], |
| 134 | + }; |
| 135 | +} |
0 commit comments