Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,19 +633,41 @@ export class ActorsMcpServer {
});
}

/**
* Token sources in order: per-request `_meta.apifyToken` (stdio inline) > server-instance
* option (set by the transport from `Authorization` header or stdio env). No env fallback:
* dev_server / production must extract the token from request headers so payment
* mode (no token) behaves identically to production.
*/
private resolveApifyToken(meta?: ApifyRequestParams['_meta']): string | undefined {
return meta?.apifyToken || this.options.token;
}

private setupResourceHandlers(): void {
const resourceService = createResourceService({
paymentProvider: this.options.paymentProvider,
getMode: () => this.serverMode,
getAvailableWidgets: () => this.availableWidgets,
});

// Build a token-scoped client for resources/read (the API proxy needs auth). This is deliberately
// token-only: unlike the CallTool path it does NOT forward provider/payment headers, so a
// payment-only session (x402/Skyfire, no Apify token) has no client and every read soft-fails by
// design. Resources are scoped to token sessions; the server-instructions state a read needs a token.
const resolveApifyClient = (params: ApifyRequestParams): ApifyClient | undefined => {
const token = this.resolveApifyToken(params._meta);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium — cross-repo] Hosted _meta.apifyToken may not be populated for resources/read. Nothing in this repo writes _meta.apifyToken — both transports inject only mcpSessionId and pass the token via options.token. Per CLAUDE.md, per-request token injection lives in apify-mcp-server-internal. Until this PR only tools/call needed a token, so if the hosted transport injects _meta.apifyToken only on the CallTool path, hosted resources/read will fall back to options.token (often undefined) and soft-fail for authenticated users. Please confirm the internal transport injects the token for ReadResource requests too before relying on this in production.

return token ? new ApifyClient({ token }) : undefined;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] resources/read is unusable in payment (x402/Skyfire) mode. resolveApifyClient only ever builds new ApifyClient({ token }). In payment mode there is no token (dev_server.ts sets apifyToken: undefined when a provider is present), so resolveApifyToken returns undefined → this returns undefinedreadApiResource short-circuits to "Cannot read …: no Apify token in this session." for every Apify-API URL.

It also never forwards the paymentHeaders that the CallTool path attaches via prepareToolCallContext (src/payments/helpers.ts:63-67new ApifyClient({ paymentHeaders })), so even billing-by-headers can't work here. The feature the server-instructions advertise is dead in the exact mode those instructions target. Either build the client the same way CallTool does (provider/payment headers + request headers) or scope/document that resources are token-only.

@RobertCrupa RobertCrupa Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should open another issue for this and just leave it documented for now.

@jirispilka do you agree?

};

this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
return await resourceService.listResources();
});

this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
return await resourceService.readResource(request.params.uri);
return await resourceService.readResource(
request.params.uri,
resolveApifyClient(request.params as ApifyRequestParams),
);
});

this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
Expand Down Expand Up @@ -819,12 +841,7 @@ export class ActorsMcpServer {
// eslint-disable-next-line prefer-const
let { name, arguments: args, _meta: meta } = params;
const progressToken = meta?.progressToken;
const metaApifyToken = meta?.apifyToken;
// Token sources in order: per-request `_meta.apifyToken` (stdio inline) > server-instance
// option (set by the transport from `Authorization` header or stdio env). No env fallback:
// dev_server / production must extract the token from request headers so payment
// mode (no token) behaves identically to production.
const apifyToken = (metaApifyToken || this.options.token) as string;
const apifyToken = this.resolveApifyToken(meta) as string;
// mcpSessionId was injected upstream it is important and required for long running tasks as the store uses it and there is not other way to pass it
const mcpSessionId = meta?.mcpSessionId;
if (!mcpSessionId) {
Expand Down
35 changes: 33 additions & 2 deletions src/resources/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,44 @@

↑ [src/](../AGENTS.md) · sideways: [`../web/AGENTS.md`](../web/AGENTS.md)

Two files serving the MCP `resources/*` surface:
Three files serving the MCP `resources/*` surface:

- `resource_service.ts` — handles `ListResources` / `ListResourceTemplates` /
read-resource requests.
read-resource requests. Takes an optional `apifyClient` on list/read; the server
builds it from the per-request token (`_meta.apifyToken || options.token`). This is
token-only by design — it does not forward payment headers like the CallTool path, so a
payment-only session (x402/Skyfire, no token) gets no client and every read soft-fails.
- `api_resources.ts` — a thin MCP-resource proxy over the Apify API: any Apify API GET
endpoint is readable as a resource, identified by its real API URL.
- `widgets.ts` — the registry of UI widgets (the metadata that maps a widget name to
its resource); the widgets themselves are built in [`../web`](../web/AGENTS.md).

## API resources (`api_resources.ts`)

Resource URIs are real Apify API GET URLs (`https://api.apify.com/v2/...`), so URLs that
Actors and tools return in their responses can be read back verbatim — no scheme to
translate. `isApifyApiUri()` gates reads to the configured API origin
(`getApifyAPIBaseUrl()`): the apify-client attaches the session token as an `Authorization`
header to **every** outbound request, so we must never hand it a non-Apify host.

`readApiResource()` is a generic proxy: `apifyClient.httpClient.call({ method: 'GET', responseType: 'arraybuffer' })`
(do **not** set `forceBuffer` — that skips the client's Content-Type parsing). The parsed
body is JSON → object, text/xml → string, anything else → `Buffer`, empty → `undefined`.
Binary and empty bodies are keyed off the JS type; a JSON body is re-serialized with `JSON.stringify`
(keyed off the declared Content-Type) so `null` and bare-string primitives round-trip, and text/xml
strings are emitted verbatim. Buffers over `KV_RECORD_MAX_INLINE_BYTES`
(256 KB) link out — an explanatory `text/plain` block naming the URL + size + type
(`resources/read` has no `resource_link` content type) — instead of inlining base64. For a KVS
record the URL is the store's `recordPublicUrl` (auth-free only when the store has a URL-signing key);
an unsigned record URL and every other endpoint fall back to the token-gated API URL, so the block says
the link may require the token. Text/JSON bodies are not size-capped (the model paginates via
`limit`/`offset`). Errors never throw: a missing resource, bad token, or 5xx returns an explanatory
`text` block.

Discovery is the server-instructions prose, not a fixed list: `resources/templates/list` returns
nothing and `resources/list` serves only widgets + the usage guide. The read path is a generic
proxy, so any Apify API GET URL works whether or not it was ever listed.

## Gotcha

`widgets.ts` is **metadata only** — it registers and locates widgets. The actual
Expand Down
163 changes: 163 additions & 0 deletions src/resources/api_resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type {
BlobResourceContents,
ReadResourceResult,
TextResourceContents,
} from '@modelcontextprotocol/sdk/types.js';

import type { ApifyClient } from '../apify_client.js';
import { getApifyAPIBaseUrl } from '../apify_client.js';
import { classifyBinaryRecord } from '../tools/common/storage_helpers.js';
import { getHttpStatusCode, logHttpError } from '../utils/logging.js';

const JSON_MIME_TYPE = 'application/json';
const TEXT_MIME_TYPE = 'text/plain';

/** True when the declared Content-Type is JSON, so the body must be re-serialized to round-trip primitives. */
function isJsonContentType(contentType: string | undefined): boolean {
return contentType?.split(';')[0].trim().toLowerCase() === JSON_MIME_TYPE;
}

/**
* True when the URI is an Apify API URL (same origin as the configured API base).
*
* This is the security gate for the generic read proxy: the apify-client attaches the
* session token as an `Authorization` header to every outbound request, so we must only
* hand it Apify API URLs — never an arbitrary host.
*/
export function isApifyApiUri(uri: string): boolean {
try {
return new URL(uri).origin === new URL(getApifyAPIBaseUrl()).origin;
} catch {
return false;
}
}

/**
* Matches an Apify key-value-store record path, capturing the store id and the record key.
* Both groups exclude `/?#` so a trailing query or fragment can't leak into the captured key.
*/
const KV_RECORD_PATH_RE = /^\/v2\/key-value-stores\/([^/?#]+)\/records\/([^/?#]+)$/;

/** `decodeURIComponent` that returns the input unchanged on malformed percent-encoding instead of throwing. */
function safeDecodeURIComponent(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}

/**
* Download URL for a binary too large to inline. For a key-value-store record URI, returns the
* store's signed `recordPublicUrl` — fetchable without an API token when the client can read the
* store's URL signing key. Falls back to the original API URL for any other endpoint, or if minting
* the signed URL fails (fetching that link then needs a token).
*/
async function fetchRecordDownloadUrl(uri: string, apifyClient: ApifyClient): Promise<string> {
let pathname: string;
try {
pathname = new URL(uri).pathname;
} catch {
return uri;
}
const match = KV_RECORD_PATH_RE.exec(pathname);
if (!match) return uri;
try {
const store = apifyClient.keyValueStore(safeDecodeURIComponent(match[1]));
return await store.getRecordPublicUrl(safeDecodeURIComponent(match[2]));
} catch (err) {
logHttpError(err, `Failed to mint signed download URL for ${uri}; falling back to API URL`);
return uri;
}
}

/**
* Single text-contents result. Defaults to text/plain (errors, refusals, link-outs); pass
* `mimeType` to preserve a body's declared Content-Type (e.g. an empty record).
*/
function buildTextResult(uri: string, text: string, mimeType: string = TEXT_MIME_TYPE): ReadResourceResult {
return { contents: [{ uri, mimeType, text } satisfies TextResourceContents] };
}

/**
* Read any Apify API GET endpoint as an MCP resource.
*
* A thin proxy: the apify-client injects the session token (and the MCP-origin header),
* performs the GET, and parses the body by Content-Type — JSON to an object, text/xml to a
* string, anything else to a Buffer, an empty body to `undefined`. We branch on that resulting
* JS type, not the MIME type. Errors (a missing resource, a bad token, a 5xx) never throw; they
* return an explanatory text block, matching the resources/read soft-fail contract.
*/
export async function readApiResource(uri: string, apifyClient?: ApifyClient): Promise<ReadResourceResult> {
if (!apifyClient) {
return buildTextResult(uri, `Cannot read ${uri}: no Apify token in this session.`);
}
if (!isApifyApiUri(uri)) {
return buildTextResult(
uri,
`Cannot read ${uri}: only Apify API URLs (${getApifyAPIBaseUrl()}) are readable as resources.`,
);
}

let response: { data: unknown; headers: Record<string, unknown> };
try {
// Default responseType is `arraybuffer`, which lets the client's parse interceptor decode
// the body by Content-Type. Do NOT set `forceBuffer` — that would keep everything as raw bytes.
response = await apifyClient.httpClient.call({ url: uri, method: 'GET', responseType: 'arraybuffer' });
} catch (err) {
const status = getHttpStatusCode(err);
const message = err instanceof Error ? err.message : String(err);
return buildTextResult(uri, `Failed to read ${uri}: ${status ? `HTTP ${status}: ` : ''}${message}`);
}

const contentTypeHeader = response.headers['content-type'];
const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined;
const { data } = response;

// An empty response body (e.g. an Actor that wrote an empty OUTPUT) maps to `undefined`; emit empty
// text, preserving the record's declared Content-Type. JSON `null` maps to JS `null` — a distinct,
// meaningful value — so it is NOT treated as empty here and round-trips through the JSON branch below.
if (data === undefined) {
return buildTextResult(uri, '', contentType);
}

if (Buffer.isBuffer(data)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium — reuse] This binary branch duplicates get_key_value_store_record.ts:86-139. The MIME-strip (contentType?.split(';')[0].trim().toLowerCase()), the > KV_RECORD_MAX_INLINE_BYTES link-out via getRecordPublicUrl, the base64 inline, and the ...(mimeType && { mimeType }) spread are all re-implemented here. Any change to the inline threshold, MIME normalization, or link-out policy now has to be made in two places and will drift. Extract a shared helper (e.g. in storage_helpers.ts) that takes (contentType, buffer, uri) and returns either an inline blob descriptor or a link-out, and call it from both.

const disposition = classifyBinaryRecord(contentType, data);
// Above the inline limit, link out with an explanatory text block (resources/read has no
// resource_link content type), matching the soft-fail contract. The link is only auth-free for a
// key-value-store record whose store has a URL-signing key; an unsigned record URL and every other
// (token-gated) API URL still need the Apify token — so the message says it may require it.
if (disposition.kind === 'linkOut') {
const downloadUrl = await fetchRecordDownloadUrl(uri, apifyClient);
return buildTextResult(
uri,
`Content (${disposition.mimeType ?? 'binary'}, ${disposition.bytes} bytes) is too large to inline. ` +
`Download it from ${downloadUrl} (may require your Apify API token).`,
);
}
return {
contents: [
{
uri,
...(disposition.mimeType && { mimeType: disposition.mimeType }),
blob: disposition.base64,
} satisfies BlobResourceContents,
],
};
}

// A JSON body must be re-serialized so primitives round-trip as valid JSON: a bare JSON string
// `"hi"` parses to the JS string `hi`, and emitting it verbatim would drop the quotes; JSON `null`
// must stay `null`, not collapse to empty text. Branch on the declared Content-Type, not the parsed
// JS type, so the type alone can't misclassify a string body.
if (isJsonContentType(contentType)) {
return buildTextResult(uri, JSON.stringify(data), contentType ?? JSON_MIME_TYPE);
}
// text/xml bodies are JS strings, emitted verbatim with their FULL declared Content-Type — charset
// included, since a client needs it to decode the text. This is deliberately unlike the binary path,
// where `classifyBinaryRecord` strips the Content-Type to its base MIME type (only the base type is
// meaningful for a blob, and the image/audio routing keys off it). Any other parsed object (no/unknown
// Content-Type) is lossless-serialized as JSON.
const text = typeof data === 'string' ? data : JSON.stringify(data);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] JSON primitive bodies lose fidelity. Because the code branches on the parsed JS type rather than the MIME type, two cases corrupt:

  • A body that is literal JSON null is parsed by apify-client to JS null, hits the data === null branch above, and is emitted as empty text — the null value is silently dropped and looks like an absent record.
  • A body that is a bare JSON string (e.g. "hello") is parsed to the JS string hello; typeof data === 'string' is true, so it's emitted verbatim as hello with mimeType: application/jsoninvalid JSON (quotes lost), and indistinguishable from a text/plain body.

Consider re-serializing with JSON.stringify when the declared Content-Type is JSON, so primitives round-trip.

return buildTextResult(uri, text, contentType ?? (typeof data === 'string' ? TEXT_MIME_TYPE : JSON_MIME_TYPE));
}
17 changes: 14 additions & 3 deletions src/resources/resource_service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
BlobResourceContents,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Expand All @@ -8,12 +9,15 @@ import type {

import log from '@apify/log';

import type { ApifyClient } from '../apify_client.js';
import type { PaymentProvider } from '../payments/types.js';
import { ServerMode } from '../types.js';
import { isApifyApiUri, readApiResource } from './api_resources.js';
import type { AvailableWidget } from './widgets.js';
import { RESOURCE_MIME_TYPE } from './widgets.js';

type ExtendedResourceContents = TextResourceContents & {
// API reads can yield binary blob contents, not just text; the widget fields are optional add-ons.
type ExtendedResourceContents = (TextResourceContents | BlobResourceContents) & {
html?: string;
_meta?: AvailableWidget['meta'];
};
Expand All @@ -24,7 +28,7 @@ type ExtendedReadResourceResult = Omit<ReadResourceResult, 'contents'> & {

type ResourceService = {
listResources: () => Promise<ListResourcesResult>;
readResource: (uri: string) => Promise<ExtendedReadResourceResult>;
readResource: (uri: string, apifyClient?: ApifyClient) => Promise<ExtendedReadResourceResult>;
listResourceTemplates: () => Promise<ListResourceTemplatesResult>;
};

Expand Down Expand Up @@ -76,7 +80,12 @@ export function createResourceService(options: ResourceServiceOptions): Resource
return { resources };
};

const readResource = async (uri: string): Promise<ExtendedReadResourceResult> => {
const readResource = async (uri: string, apifyClient?: ApifyClient): Promise<ExtendedReadResourceResult> => {
if (isApifyApiUri(uri)) {
// API contents carry no widget `_meta`/`html`; the extended shape only adds optional fields.
return (await readApiResource(uri, apifyClient)) as ExtendedReadResourceResult;
}

const usageGuide = paymentProvider?.getUsageGuide?.();
if (usageGuide && uri === 'file://readme.md') {
return {
Expand Down Expand Up @@ -158,6 +167,8 @@ export function createResourceService(options: ResourceServiceOptions): Resource
};
};

// Read is a generic proxy over any Apify API GET URL, advertised in the server instructions;
// there are no fixed templates to enumerate.
const listResourceTemplates = async (): Promise<ListResourceTemplatesResult> => ({
resourceTemplates: [],
});
Expand Down
15 changes: 9 additions & 6 deletions src/tools/common/get_key_value_store_record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AudioContent, EmbeddedResource, ImageContent, ResourceLink } from
import dedent from 'dedent';
import { z } from 'zod';

import { HelperTools, KV_RECORD_MAX_INLINE_BYTES } from '../../const.js';
import { HelperTools } from '../../const.js';
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
import { TOOL_TYPE } from '../../types.js';
import { compileSchema } from '../../utils/ajv.js';
Expand All @@ -13,6 +13,7 @@ import {
buildConsoleLinkContent,
buildStorageNotFound,
buildStorageResponse,
classifyBinaryRecord,
normalizeRecordKey,
} from './storage_helpers.js';

Expand Down Expand Up @@ -84,8 +85,10 @@ export const getKeyValueStoreRecord: ToolEntry = Object.freeze({
// structuredContent — so emit a minimal schema-conforming descriptor alongside the block.
// The Console link (Console UI token sessions) rides as a trailing text block.
if (Buffer.isBuffer(value)) {
// Content-Type is case-insensitive; lowercase so the image/audio checks below don't miss `Image/PNG`.
const mimeType = contentType?.split(';')[0].trim().toLowerCase();
// Shared with the API-resource proxy: normalizes the MIME type (so the image/audio checks below
// don't miss `Image/PNG`) and decides inline-vs-link-out at the same byte threshold.
const disposition = classifyBinaryRecord(contentType, value);
const { mimeType } = disposition;
const structuredContent = {
keyValueStoreId,
key: record.key,
Expand All @@ -94,7 +97,7 @@ export const getKeyValueStoreRecord: ToolEntry = Object.freeze({
summary,
};
const consoleLinkContent = buildConsoleLinkContent(apifyConsoleUrl);
if (value.length > KV_RECORD_MAX_INLINE_BYTES) {
if (disposition.kind === 'linkOut') {
// base64-inlining a large binary would blow up the context window; return a link instead.
const uri = await store.getRecordPublicUrl(recordKey);
return {
Expand All @@ -104,14 +107,14 @@ export const getKeyValueStoreRecord: ToolEntry = Object.freeze({
type: 'resource_link',
uri,
name: recordKey,
size: value.length,
size: disposition.bytes,
...(mimeType && { mimeType }),
} satisfies ResourceLink,
...consoleLinkContent,
],
};
}
const data = value.toString('base64');
const data = disposition.base64;
if (mimeType?.startsWith('image/')) {
return {
structuredContent,
Expand Down
Loading