Skip to content

Commit 2733d4e

Browse files
MQ37MichalKalita
andauthored
feat: agentic payments v2 (#266)
* feat: improve actor tool output * update readme * fix output tool, write test for that * add test based on Zuzka suggestion * lint * fix output response order so LLM does not lose the instructions * refactor: unify string list parsing logic * fix the tests - order of the Actor run response messages * Update src/utils/schema-generation.ts Co-authored-by: Michal Kalita <[email protected]> * address review comments * feat: agentci payments v2 * add skyfire usage resource, fix skyfire pay id handling and passing to Actor * add skyfire instructions also to the call-actor info step result content * Squashed commit of the following: commit 20e6753 Author: Apify Release Bot <[email protected]> Date: Tue Sep 9 11:41:31 2025 +0000 chore(release): Update changelog, package.json and manifest.json versions [skip ci] commit 7ef726d Author: Jakub Kopecký <[email protected]> Date: Tue Sep 9 13:40:04 2025 +0200 feat: improve actor tool output (#260) * feat: improve actor tool output * update readme * fix output tool, write test for that * add test based on Zuzka suggestion * lint * fix output response order so LLM does not lose the instructions * refactor: unify string list parsing logic * fix the tests - order of the Actor run response messages * Update src/utils/schema-generation.ts Co-authored-by: Michal Kalita <[email protected]> * address review comments * add get-actor-output tools note about when its loaded --------- Co-authored-by: Michal Kalita <[email protected]> commit 279293f Author: Michal Kalita <[email protected]> Date: Mon Sep 8 12:05:25 2025 +0200 fix: error when content type is json (#265) * fix: error when content type is json * fix: do not make json schema formatted for human readable commit 4659e03 Author: Apify Release Bot <[email protected]> Date: Thu Sep 4 12:30:50 2025 +0000 chore(release): Update changelog, package.json and manifest.json versions [skip ci] * fix the port already in use issue with tests * lint * remove the try catch that was rethrowing generic error in callActorGetDataset * add skyfire seller id and pay id to the get actor output * lint * rename env var * include seller id conditionally in the instructions * handle skyfire pay id in get actor output * change usd amount --------- Co-authored-by: Michal Kalita <[email protected]>
1 parent 89b96d1 commit 2733d4e

22 files changed

+382
-162
lines changed

src/actor/server.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import express from 'express';
1111

1212
import log from '@apify/log';
1313

14+
import { ApifyClient } from '../apify-client.js';
1415
import { ActorsMcpServer } from '../mcp/server.js';
1516
import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js';
1617
import { getActorRunData } from './utils.js';
@@ -69,13 +70,14 @@ export function createExpressApp(
6970
rt: Routes.SSE,
7071
tr: TransportType.SSE,
7172
});
72-
const mcpServer = new ActorsMcpServer(false);
73+
const mcpServer = new ActorsMcpServer({ setupSigintHandler: false });
7374
const transport = new SSEServerTransport(Routes.MESSAGE, res);
7475

7576
// Load MCP server tools
7677
const apifyToken = process.env.APIFY_TOKEN as string;
7778
log.debug('Loading tools from URL', { sessionId: transport.sessionId, tr: TransportType.SSE });
78-
await mcpServer.loadToolsFromUrl(req.url, apifyToken);
79+
const apifyClient = new ApifyClient({ token: apifyToken });
80+
await mcpServer.loadToolsFromUrl(req.url, apifyClient);
7981

8082
transportsSSE[transport.sessionId] = transport;
8183
mcpServers[transport.sessionId] = mcpServer;
@@ -152,12 +154,13 @@ export function createExpressApp(
152154
sessionIdGenerator: () => randomUUID(),
153155
enableJsonResponse: false, // Use SSE response mode
154156
});
155-
const mcpServer = new ActorsMcpServer(false);
157+
const mcpServer = new ActorsMcpServer({ setupSigintHandler: false });
156158

157159
// Load MCP server tools
158160
const apifyToken = process.env.APIFY_TOKEN as string;
159161
log.debug('Loading tools from URL', { sessionId: transport.sessionId, tr: TransportType.HTTP });
160-
await mcpServer.loadToolsFromUrl(req.url, apifyToken);
162+
const apifyClient = new ApifyClient({ token: apifyToken });
163+
await mcpServer.loadToolsFromUrl(req.url, apifyClient);
161164

162165
// Connect the transport to the MCP server BEFORE handling the request
163166
await mcpServer.connect(transport);

src/apify-client.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import type { AxiosRequestConfig } from 'axios';
44

55
import { USER_AGENT_ORIGIN } from './const.js';
66

7+
interface ExtendedApifyClientOptions extends Omit<ApifyClientOptions, 'token'> {
8+
token?: string | null | undefined;
9+
skyfirePayId?: string;
10+
}
11+
712
/**
813
* Adds a User-Agent header to the request config.
914
* @param config
@@ -23,22 +28,37 @@ export function getApifyAPIBaseUrl(): string {
2328
}
2429

2530
export class ApifyClient extends _ApifyClient {
26-
constructor(options: ApifyClientOptions) {
31+
constructor(options: ExtendedApifyClientOptions) {
2732
/**
2833
* In order to publish to DockerHub, we need to run their build task to validate our MCP server.
2934
* This was failing since we were sending this dummy token to Apify in order to build the Actor tools.
3035
* So if we encounter this dummy value, we remove it to use Apify client as unauthenticated, which is sufficient
3136
* for server start and listing of tools.
3237
*/
33-
if (options.token?.toLowerCase() === 'your-apify-token') {
38+
if (options.token?.toLowerCase() === 'your-apify-token' || options.token === null) {
3439
// eslint-disable-next-line no-param-reassign
3540
delete options.token;
3641
}
3742

43+
const { skyfirePayId, ...clientOptions } = options;
44+
const requestInterceptors = [addUserAgent];
45+
/**
46+
* Add skyfire-pay-id header if provided.
47+
*/
48+
if (skyfirePayId) {
49+
requestInterceptors.push((config) => {
50+
const updatedConfig = { ...config };
51+
updatedConfig.headers = updatedConfig.headers ?? {};
52+
updatedConfig.headers['skyfire-pay-id'] = skyfirePayId;
53+
return updatedConfig;
54+
});
55+
}
56+
3857
super({
39-
...options,
58+
// token null case is handled, we can assert type here
59+
...clientOptions as ApifyClientOptions,
4060
baseUrl: getApifyAPIBaseUrl(),
41-
requestInterceptors: [addUserAgent],
61+
requestInterceptors,
4262
});
4363
}
4464
}

src/const.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export const defaults = {
5353
};
5454

5555
export const ACTOR_ADDITIONAL_INSTRUCTIONS = 'Never call/execute tool/Actor unless confirmed by the user.';
56+
export const SKYFIRE_MIN_CHARGE_USD = 5.0;
57+
export const SKYFIRE_SELLER_ID = process.env.SKYFIRE_SELLER_SERVICE_ID;
58+
59+
export const SKYFIRE_TOOL_INSTRUCTIONS = `To run the Actor, you need to provide a Skyfire payment ID in the \`skyfire-pay-id\` input property. You first need to create the Skyfire PAY token by calling the \`create-pay-token\` tool from the Skyfire MCP server and then provide the created token in the \`skyfire-pay-id\` input property when calling Actor tool. The Skyfire PAY token is used for billing purposes and needs to be charged with at least ${SKYFIRE_MIN_CHARGE_USD} USD.${SKYFIRE_SELLER_ID ? ` The Skyfire seller ID for Apify MCP server is "${SKYFIRE_SELLER_ID}".` : ''} When retrieving Actor output using the \`get-actor-output\` tool, you also need to provide the same Skyfire PAY token ID in the \`skyfire-pay-id\` input property.`;
60+
61+
export const SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION = `Skyfire PAY token ID used for billing purposes.`;
62+
63+
export const SKYFIRE_README_CONTENT = `The Apify MCP Server allows clients to interact with Apify Actors, storage, and documentation. Before running any Apify Actor, you need to set up a Skyfire PAY token for billing purposes. To set up the PAY token you need to use the create-pay-token tool from the Skyfire MCP server. The PAY token must be charged with a minimum of ${SKYFIRE_MIN_CHARGE_USD} USD to be valid.${SKYFIRE_SELLER_ID ? ` The Skyfire seller ID for Apify MCP server is "${SKYFIRE_SELLER_ID}".` : ''} When running an Actor using the call-actor tool, you must provide the Skyfire PAY token ID in the \`skyfire-pay-id\` input property. Similarly, when retrieving Actor output using the get-actor-output tool, you must also provide the same Skyfire PAY token ID in the \`skyfire-pay-id\` input property.`;
5664

5765
// Cache
5866
export const ACTOR_CACHE_MAX_SIZE = 500;

src/index-internals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
This file provides essential internal functions for Apify MCP servers, serving as an internal library.
33
*/
44

5+
import { ApifyClient } from './apify-client.js';
56
import { defaults, HelperTools } from './const.js';
67
import { processParamsGetTools } from './mcp/utils.js';
78
import { addTool } from './tools/helpers.js';
@@ -12,6 +13,7 @@ import { getExpectedToolNamesByCategories, getToolPublicFieldOnly } from './util
1213
import { TTLLRUCache } from './utils/ttl-lru.js';
1314

1415
export {
16+
ApifyClient,
1517
getExpectedToolNamesByCategories,
1618
TTLLRUCache,
1719
actorNameToToolName,

src/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ActorCallOptions } from 'apify-client';
99
import log from '@apify/log';
1010

1111
import { createExpressApp } from './actor/server.js';
12+
import { ApifyClient } from './apify-client.js';
1213
import { processInput } from './input.js';
1314
import { callActorGetDataset } from './tools/index.js';
1415
import type { Input } from './types.js';
@@ -44,7 +45,9 @@ if (STANDBY_MODE) {
4445
await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input');
4546
}
4647
const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions;
47-
const callResult = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);
48+
49+
const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN });
50+
const callResult = await callActorGetDataset(input.debugActor!, input.debugActorInput!, apifyClient, options);
4851

4952
if (callResult && callResult.previewItems.length > 0) {
5053
await Actor.pushData(callResult.previewItems);

src/mcp/actors.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,3 @@ export async function getRealActorID(actorIdOrName: string, apifyToken: string):
6464
export async function getActorStandbyURL(realActorId: string, standbyBaseUrl = 'apify.actor'): Promise<string> {
6565
return `https://${realActorId}.${standbyBaseUrl}`;
6666
}
67-
68-
export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
69-
const apifyClient = new ApifyClient({ token: apifyToken });
70-
const actor = apifyClient.actor(actorID);
71-
const defaultBuildClient = await actor.defaultBuild();
72-
const buildInfo = await defaultBuildClient.get();
73-
if (!buildInfo) {
74-
throw new Error(`Default build for Actor ${actorID} not found`);
75-
}
76-
const { actorDefinition } = buildInfo;
77-
if (!actorDefinition) {
78-
throw new Error(`Actor default build ${actorID} does not have Actor definition`);
79-
}
80-
81-
return actorDefinition;
82-
}

src/mcp/server.ts

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ListResourceTemplatesRequestSchema,
1616
ListToolsRequestSchema,
1717
McpError,
18+
ReadResourceRequestSchema,
1819
ServerNotificationSchema,
1920
SetLevelRequestSchema,
2021
} from '@modelcontextprotocol/sdk/types.js';
@@ -23,9 +24,14 @@ import { type ActorCallOptions, ApifyApiError } from 'apify-client';
2324

2425
import log from '@apify/log';
2526

27+
import { ApifyClient } from '../apify-client.js';
2628
import {
29+
HelperTools,
2730
SERVER_NAME,
2831
SERVER_VERSION,
32+
SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION,
33+
SKYFIRE_README_CONTENT,
34+
SKYFIRE_TOOL_INSTRUCTIONS,
2935
} from '../const.js';
3036
import { prompts } from '../prompts/index.js';
3137
import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js';
@@ -40,6 +46,14 @@ import { processParamsGetTools } from './utils.js';
4046

4147
type ToolsChangedHandler = (toolNames: string[]) => void;
4248

49+
interface ActorsMcpServerOptions {
50+
setupSigintHandler?: boolean;
51+
/**
52+
* Switch to enable Skyfire agentic payment mode.
53+
*/
54+
skyfireMode?: boolean;
55+
}
56+
4357
/**
4458
* Create Apify MCP server
4559
*/
@@ -49,8 +63,11 @@ export class ActorsMcpServer {
4963
private toolsChangedHandler: ToolsChangedHandler | undefined;
5064
private sigintHandler: (() => Promise<void>) | undefined;
5165
private currentLogLevel = 'info';
66+
public readonly options: ActorsMcpServerOptions;
5267

53-
constructor(setupSigintHandler = true) {
68+
constructor(options: ActorsMcpServerOptions = {}) {
69+
this.options = options;
70+
const { setupSigintHandler = true } = options;
5471
this.server = new Server(
5572
{
5673
name: SERVER_NAME,
@@ -161,7 +178,7 @@ export class ActorsMcpServer {
161178
* @param toolNames - Array of tool names to ensure are loaded
162179
* @param apifyToken - Apify API token for authentication
163180
*/
164-
public async loadToolsByName(toolNames: string[], apifyToken: string) {
181+
public async loadToolsByName(toolNames: string[], apifyClient: ApifyClient) {
165182
const loadedTools = this.listAllToolNames();
166183
const actorsToLoad: string[] = [];
167184
const toolsToLoad: ToolEntry[] = [];
@@ -186,7 +203,7 @@ export class ActorsMcpServer {
186203
}
187204

188205
if (actorsToLoad.length > 0) {
189-
await this.loadActorsAsTools(actorsToLoad, apifyToken);
206+
await this.loadActorsAsTools(actorsToLoad, apifyClient);
190207
}
191208
}
192209

@@ -197,8 +214,8 @@ export class ActorsMcpServer {
197214
* @param apifyToken - Apify API token for authentication
198215
* @returns Promise<ToolEntry[]> - Array of loaded tool entries
199216
*/
200-
public async loadActorsAsTools(actorIdsOrNames: string[], apifyToken: string): Promise<ToolEntry[]> {
201-
const actorTools = await getActorsAsTools(actorIdsOrNames, apifyToken);
217+
public async loadActorsAsTools(actorIdsOrNames: string[], apifyClient: ApifyClient): Promise<ToolEntry[]> {
218+
const actorTools = await getActorsAsTools(actorIdsOrNames, apifyClient);
202219
if (actorTools.length > 0) {
203220
this.upsertTools(actorTools, true);
204221
}
@@ -212,8 +229,8 @@ export class ActorsMcpServer {
212229
*
213230
* Used primarily for SSE.
214231
*/
215-
public async loadToolsFromUrl(url: string, apifyToken: string) {
216-
const tools = await processParamsGetTools(url, apifyToken);
232+
public async loadToolsFromUrl(url: string, apifyClient: ApifyClient) {
233+
const tools = await processParamsGetTools(url, apifyClient);
217234
if (tools.length > 0) {
218235
log.debug('Loading tools from query parameters');
219236
this.upsertTools(tools, false);
@@ -307,10 +324,44 @@ export class ActorsMcpServer {
307324

308325
private setupResourceHandlers(): void {
309326
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
310-
// No resources available, return empty response
327+
/**
328+
* Return the usage guide resource only if Skyfire mode is enabled. No resources otherwise for normal mode.
329+
*/
330+
if (this.options.skyfireMode) {
331+
return {
332+
resources: [
333+
{
334+
uri: 'file://readme.md',
335+
name: 'readme',
336+
description: `Apify MCP Server usage guide. Read this to understand how to use the server, especially in Skyfire mode before interacting with it.`,
337+
mimeType: 'text/markdown',
338+
},
339+
],
340+
};
341+
}
311342
return { resources: [] };
312343
});
313344

345+
if (this.options.skyfireMode) {
346+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
347+
const { uri } = request.params;
348+
if (uri === 'file://readme.md') {
349+
return {
350+
contents: [{
351+
uri: 'file://readme.md',
352+
mimeType: 'text/markdown',
353+
text: SKYFIRE_README_CONTENT,
354+
}],
355+
};
356+
}
357+
return {
358+
contents: [{
359+
uri, mimeType: 'text/plain', text: `Resource ${uri} not found`,
360+
}],
361+
};
362+
});
363+
}
364+
314365
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
315366
// No resource templates available, return empty response
316367
return { resourceTemplates: [] };
@@ -368,6 +419,26 @@ export class ActorsMcpServer {
368419
* @returns {object} - The response object containing the tools.
369420
*/
370421
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
422+
/**
423+
* Hack for the Skyfire agentic payments, we check if Skyfire mode is enabled we ad-hoc add
424+
* the `skyfire-pay-id` input property to all Actor tools and `call-actor` and `get-actor-output` tool.
425+
*/
426+
if (this.options.skyfireMode) {
427+
for (const toolEntry of this.tools.values()) {
428+
if (toolEntry.type === 'actor'
429+
|| (toolEntry.type === 'internal' && toolEntry.tool.name === HelperTools.ACTOR_CALL)
430+
|| (toolEntry.type === 'internal' && toolEntry.tool.name === HelperTools.ACTOR_OUTPUT_GET)) {
431+
if (toolEntry.tool.inputSchema && 'properties' in toolEntry.tool.inputSchema) {
432+
(toolEntry.tool.inputSchema.properties as Record<string, unknown>)['skyfire-pay-id'] = {
433+
type: 'string',
434+
description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION,
435+
};
436+
}
437+
// Update description to include Skyfire instructions
438+
toolEntry.tool.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`;
439+
}
440+
}
441+
}
371442
const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool));
372443
return { tools };
373444
});
@@ -391,7 +462,7 @@ export class ActorsMcpServer {
391462
delete request.params.userRentedActorIds;
392463

393464
// Validate token
394-
if (!apifyToken) {
465+
if (!apifyToken && !this.options.skyfireMode) {
395466
const msg = 'APIFY_TOKEN is required. It must be set in the environment variables or passed as a parameter in the body.';
396467
log.error(msg);
397468
await this.server.sendLoggingMessage({ level: 'error', data: msg });
@@ -516,19 +587,38 @@ export class ActorsMcpServer {
516587

517588
// Handle actor tool
518589
if (tool.type === 'actor') {
590+
if (this.options.skyfireMode
591+
&& args['skyfire-pay-id'] === undefined
592+
) {
593+
return {
594+
content: [{
595+
type: 'text',
596+
text: SKYFIRE_TOOL_INSTRUCTIONS,
597+
}],
598+
};
599+
}
600+
519601
const actorTool = tool.tool as ActorTool;
520602

521603
// Create progress tracker if progressToken is available
522604
const progressTracker = createProgressTracker(progressToken, extra.sendNotification);
523605

524606
const callOptions: ActorCallOptions = { memory: actorTool.memoryMbytes };
525607

608+
/**
609+
* Create Apify token, for Skyfire mode use `skyfire-pay-id` and for normal mode use `apifyToken`.
610+
*/
611+
const { 'skyfire-pay-id': skyfirePayId, ...actorArgs } = args as Record<string, unknown>;
612+
const apifyClient = this.options.skyfireMode && typeof skyfirePayId === 'string'
613+
? new ApifyClient({ skyfirePayId })
614+
: new ApifyClient({ token: apifyToken });
615+
526616
try {
527-
log.info('Calling Actor', { actorName: actorTool.actorFullName, input: args });
617+
log.info('Calling Actor', { actorName: actorTool.actorFullName, input: actorArgs });
528618
const callResult = await callActorGetDataset(
529619
actorTool.actorFullName,
530-
args,
531-
apifyToken as string,
620+
actorArgs,
621+
apifyClient,
532622
callOptions,
533623
progressTracker,
534624
extra.signal,

0 commit comments

Comments
 (0)