Skip to content

Commit 9387113

Browse files
committed
feat: Expose Apify API GET endpoints as MCP resources
Implement resources/read as a generic proxy over any Apify API GET URL, identified by its real api.apify.com URL. The apify-client injects the session token; reads are gated to the configured API origin (isApifyApiUri) so the token is never sent to another host. Bodies are returned by parsed type — JSON as text, text verbatim, binaries as a base64 blob — and binaries over the inline limit link out as a text/plain message. Missing resources, bad tokens, and 5xx soft-fail as text rather than throwing. resources/list and resources/templates/list stay minimal (widgets / usage-guide only); discovery is the prose in server instructions, which document the URL shapes and to page large datasets with limit/offset. Closes the first part of #998.
1 parent 16d8c7e commit 9387113

8 files changed

Lines changed: 400 additions & 7 deletions

File tree

src/mcp/server.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,12 +640,22 @@ export class ActorsMcpServer {
640640
getAvailableWidgets: () => this.availableWidgets,
641641
});
642642

643+
// Resolve the token like the CallTool handler and build a client when one is present.
644+
// Only resources/read is token-scoped (the API proxy needs auth); without a token reads soft-fail.
645+
const resolveApifyClient = (params: ApifyRequestParams): ApifyClient | undefined => {
646+
const token = (params._meta?.apifyToken || this.options.token) as string | undefined;
647+
return token ? new ApifyClient({ token }) : undefined;
648+
};
649+
643650
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
644651
return await resourceService.listResources();
645652
});
646653

647654
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
648-
return await resourceService.readResource(request.params.uri);
655+
return await resourceService.readResource(
656+
request.params.uri,
657+
resolveApifyClient(request.params as ApifyRequestParams),
658+
);
649659
});
650660

651661
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {

src/resources/AGENTS.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,39 @@
33

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

6-
Two files serving the MCP `resources/*` surface:
6+
Three files serving the MCP `resources/*` surface:
77

88
- `resource_service.ts` — handles `ListResources` / `ListResourceTemplates` /
9-
read-resource requests.
9+
read-resource requests. Takes an optional `apifyClient` on list/read; the server
10+
builds it from the per-request token (`_meta.apifyToken || options.token`).
11+
- `api_resources.ts` — a thin MCP-resource proxy over the Apify API: any Apify API GET
12+
endpoint is readable as a resource, identified by its real API URL.
1013
- `widgets.ts` — the registry of UI widgets (the metadata that maps a widget name to
1114
its resource); the widgets themselves are built in [`../web`](../web/AGENTS.md).
1215

16+
## API resources (`api_resources.ts`)
17+
18+
Resource URIs are real Apify API GET URLs (`https://api.apify.com/v2/...`), so URLs that
19+
Actors and tools return in their responses can be read back verbatim — no scheme to
20+
translate. `isApifyApiUri()` gates reads to the configured API origin
21+
(`getApifyAPIBaseUrl()`): the apify-client attaches the session token as an `Authorization`
22+
header to **every** outbound request, so we must never hand it a non-Apify host.
23+
24+
`readApiResource()` is a generic proxy: `apifyClient.httpClient.call({ method: 'GET', responseType: 'arraybuffer' })`
25+
(do **not** set `forceBuffer` — that skips the client's Content-Type parsing). The parsed
26+
body is JSON → object, text/xml → string, anything else → `Buffer`, empty → `undefined`;
27+
we branch on that JS type, not the MIME type. Buffers over `KV_RECORD_MAX_INLINE_BYTES`
28+
(256 KB) link out — an explanatory `text/plain` block naming the URL + size + type
29+
(`resources/read` has no `resource_link` content type) — instead of inlining base64. For a KVS
30+
record the URL is the store's signed `recordPublicUrl` (fetchable without a token); other endpoints
31+
fall back to the token-gated API URL. Text/JSON bodies are not size-capped (the model paginates via
32+
`limit`/`offset`). Errors never throw: a missing resource, bad token, or 5xx returns an explanatory
33+
`text` block.
34+
35+
Discovery is the server-instructions prose, not a fixed list: `resources/templates/list` returns
36+
nothing and `resources/list` serves only widgets + the usage guide. The read path is a generic
37+
proxy, so any Apify API GET URL works whether or not it was ever listed.
38+
1339
## Gotcha
1440

1541
`widgets.ts` is **metadata only** — it registers and locates widgets. The actual

src/resources/api_resources.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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+
}

src/resources/resource_service.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import type {
88

99
import log from '@apify/log';
1010

11+
import type { ApifyClient } from '../apify_client.js';
1112
import type { PaymentProvider } from '../payments/types.js';
1213
import { ServerMode } from '../types.js';
14+
import { isApifyApiUri, readApiResource } from './api_resources.js';
1315
import type { AvailableWidget } from './widgets.js';
1416
import { RESOURCE_MIME_TYPE } from './widgets.js';
1517

@@ -24,7 +26,7 @@ type ExtendedReadResourceResult = Omit<ReadResourceResult, 'contents'> & {
2426

2527
type ResourceService = {
2628
listResources: () => Promise<ListResourcesResult>;
27-
readResource: (uri: string) => Promise<ExtendedReadResourceResult>;
29+
readResource: (uri: string, apifyClient?: ApifyClient) => Promise<ExtendedReadResourceResult>;
2830
listResourceTemplates: () => Promise<ListResourceTemplatesResult>;
2931
};
3032

@@ -76,7 +78,12 @@ export function createResourceService(options: ResourceServiceOptions): Resource
7678
return { resources };
7779
};
7880

79-
const readResource = async (uri: string): Promise<ExtendedReadResourceResult> => {
81+
const readResource = async (uri: string, apifyClient?: ApifyClient): Promise<ExtendedReadResourceResult> => {
82+
if (isApifyApiUri(uri)) {
83+
// API contents carry no widget `_meta`/`html`; the extended shape only adds optional fields.
84+
return (await readApiResource(uri, apifyClient)) as ExtendedReadResourceResult;
85+
}
86+
8087
const usageGuide = paymentProvider?.getUsageGuide?.();
8188
if (usageGuide && uri === 'file://readme.md') {
8289
return {
@@ -158,6 +165,8 @@ export function createResourceService(options: ResourceServiceOptions): Resource
158165
};
159166
};
160167

168+
// Read is a generic proxy over any Apify API GET URL, advertised in the server instructions;
169+
// there are no fixed templates to enumerate.
161170
const listResourceTemplates = async (): Promise<ListResourceTemplatesResult> => ({
162171
resourceTemplates: [],
163172
});

src/utils/server-instructions/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ These tools are called **Actors**. They enable you to extract structured data fr
4444
## Storage types
4545
- **Dataset:** Structured, append-only storage ideal for tabular or list data (e.g., scraped items).
4646
- **Key-value store:** Flexible storage for unstructured data or auxiliary files.
47+
48+
## Apify API resources
49+
- Any Apify API GET endpoint can be read as an MCP resource. Pass the full \`https://api.apify.com/v2/...\` URL to resources/read; the server injects authentication and returns the response body.
50+
- Actor and tool responses often include such URLs (e.g. dataset items, key-value store records) — read them directly via resources/read, no rewriting needed.
51+
- Reads are not size-bounded; for large datasets page with \`limit\` and \`offset\` rather than reading everything at once.
52+
- Examples: \`https://api.apify.com/v2/datasets/{datasetId}/items?clean=true&format=json&limit=100\`, \`https://api.apify.com/v2/key-value-stores/{storeId}/records/{recordKey}\`.
4753
${
4854
isApps
4955
? `

tests/integration/suite.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,29 @@ export function createIntegrationTestsSuite(options: IntegrationTestsSuiteOption
21332133
expect(items![0]['math.factorial.first']).toBe(1);
21342134
await client.close();
21352135
});
2136+
2137+
it('reads dataset items via resources/read', async () => {
2138+
client = await createClientFn({ tools: ['storage'] });
2139+
const result = await client.readResource({
2140+
uri: `https://api.apify.com/v2/datasets/${datasetId}/items?limit=5`,
2141+
});
2142+
const contents = result.contents[0] as { mimeType?: string; text?: string };
2143+
expect(contents.mimeType).toBe('application/json');
2144+
// The generic proxy returns the raw API body — a bare JSON array of items.
2145+
const items = JSON.parse(contents.text as string) as unknown[];
2146+
expect(Array.isArray(items)).toBe(true);
2147+
await client.close();
2148+
});
2149+
2150+
it('reads a KV record via resources/read', async () => {
2151+
client = await createClientFn({ tools: ['storage'] });
2152+
const result = await client.readResource({
2153+
uri: `https://api.apify.com/v2/key-value-stores/${defaultKvId}/records/INPUT`,
2154+
});
2155+
const contents = result.contents[0] as { text?: string };
2156+
expect(contents.text).toContain('firstNumber');
2157+
await client.close();
2158+
});
21362159
});
21372160

21382161
it('rejects get-key-value-store-record when required keyValueStoreId is missing', async () => {

0 commit comments

Comments
 (0)