Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ad7b772
feat: improve actor tool output
MQ37 Sep 3, 2025
6baa662
Merge remote-tracking branch 'origin/master' into feat/improve-actor-…
MQ37 Sep 3, 2025
07035b0
update readme
MQ37 Sep 4, 2025
4a402d8
fix output tool, write test for that
MQ37 Sep 4, 2025
f207cc2
add test based on Zuzka suggestion
MQ37 Sep 4, 2025
4ff957e
lint
MQ37 Sep 4, 2025
a2bf3b5
fix output response order so LLM does not lose the instructions
MQ37 Sep 4, 2025
26c34cc
refactor: unify string list parsing logic
MQ37 Sep 4, 2025
3186c88
fix the tests - order of the Actor run response messages
MQ37 Sep 4, 2025
259831a
Update src/utils/schema-generation.ts
MQ37 Sep 4, 2025
863dd47
address review comments
MQ37 Sep 8, 2025
5501130
feat: agentci payments v2
MQ37 Sep 9, 2025
dde4fd2
add skyfire usage resource, fix skyfire pay id handling and passing t…
MQ37 Sep 9, 2025
83067b1
add skyfire instructions also to the call-actor info step result content
MQ37 Sep 9, 2025
07a8d19
Squashed commit of the following:
MQ37 Sep 9, 2025
eb9a159
Merge branch 'master' into feat/skyfire-payments-v2
MQ37 Sep 9, 2025
575f4cf
fix the port already in use issue with tests
MQ37 Sep 9, 2025
143833a
lint
MQ37 Sep 9, 2025
b9000e3
remove the try catch that was rethrowing generic error in callActorGe…
MQ37 Sep 9, 2025
6081fe8
add skyfire seller id and pay id to the get actor output
MQ37 Sep 10, 2025
6ae8a39
lint
MQ37 Sep 10, 2025
c24255c
rename env var
MQ37 Sep 10, 2025
784689e
include seller id conditionally in the instructions
MQ37 Sep 10, 2025
b02d4f5
handle skyfire pay id in get actor output
MQ37 Sep 10, 2025
c2015e3
change usd amount
MQ37 Sep 11, 2025
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
11 changes: 7 additions & 4 deletions src/actor/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import express from 'express';

import log from '@apify/log';

import { ApifyClient } from '../apify-client.js';
import { ActorsMcpServer } from '../mcp/server.js';
import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js';
import { getActorRunData } from './utils.js';
Expand Down Expand Up @@ -69,13 +70,14 @@ export function createExpressApp(
rt: Routes.SSE,
tr: TransportType.SSE,
});
const mcpServer = new ActorsMcpServer(false);
const mcpServer = new ActorsMcpServer({ setupSigintHandler: false });
const transport = new SSEServerTransport(Routes.MESSAGE, res);

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

transportsSSE[transport.sessionId] = transport;
mcpServers[transport.sessionId] = mcpServer;
Expand Down Expand Up @@ -152,12 +154,13 @@ export function createExpressApp(
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: false, // Use SSE response mode
});
const mcpServer = new ActorsMcpServer(false);
const mcpServer = new ActorsMcpServer({ setupSigintHandler: false });

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

// Connect the transport to the MCP server BEFORE handling the request
await mcpServer.connect(transport);
Expand Down
28 changes: 24 additions & 4 deletions src/apify-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type { AxiosRequestConfig } from 'axios';

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

interface ExtendedApifyClientOptions extends Omit<ApifyClientOptions, 'token'> {
token?: string | null | undefined;
skyfirePayId?: string;
}

/**
* Adds a User-Agent header to the request config.
* @param config
Expand All @@ -23,22 +28,37 @@ export function getApifyAPIBaseUrl(): string {
}

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

const { skyfirePayId, ...clientOptions } = options;
const requestInterceptors = [addUserAgent];
/**
* Add skyfire-pay-id header if provided.
*/
if (skyfirePayId) {
requestInterceptors.push((config) => {
const updatedConfig = { ...config };
Copy link
Contributor

Choose a reason for hiding this comment

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

This type of first level clone don't have any effect. Config object itself is not mutated.
This function mutates config.headers, and the input config object is updated.

Here is simple preview how it works
image

Use structuredClone for full clone. Or nothing if mutation is ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just copied to logic from the add user agent header and it uses this exact logic. I dont quite get what is the issue exactly here? Is there any major flaw?

updatedConfig.headers = updatedConfig.headers ?? {};
updatedConfig.headers['skyfire-pay-id'] = skyfirePayId;
return updatedConfig;
});
}

super({
...options,
// token null case is handled, we can assert type here
...clientOptions as ApifyClientOptions,
baseUrl: getApifyAPIBaseUrl(),
requestInterceptors: [addUserAgent],
requestInterceptors,
});
}
}
7 changes: 7 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export const defaults = {
};

export const ACTOR_ADDITIONAL_INSTRUCTIONS = 'Never call/execute tool/Actor unless confirmed by the user.';
export const SKYFIRE_MIN_CHARGE_USD = 0.005;

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.`;

export const SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION = `Skyfire PAY token ID used for billing purposes.`;

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.`;

// Cache
export const ACTOR_CACHE_MAX_SIZE = 500;
Expand Down
2 changes: 2 additions & 0 deletions src/index-internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
This file provides essential internal functions for Apify MCP servers, serving as an internal library.
*/

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

export {
ApifyClient,
getExpectedToolNamesByCategories,
TTLLRUCache,
actorNameToToolName,
Expand Down
5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ActorCallOptions } from 'apify-client';
import log from '@apify/log';

import { createExpressApp } from './actor/server.js';
import { ApifyClient } from './apify-client.js';
import { processInput } from './input.js';
import { callActorGetDataset } from './tools/index.js';
import type { Input } from './types.js';
Expand Down Expand Up @@ -44,7 +45,9 @@ if (STANDBY_MODE) {
await Actor.fail('If you need to debug a specific Actor, please provide the debugActor and debugActorInput fields in the input');
}
const options = { memory: input.maxActorMemoryBytes } as ActorCallOptions;
const callResult = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options);

const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN });
const callResult = await callActorGetDataset(input.debugActor!, input.debugActorInput!, apifyClient, options);

if (callResult && callResult.previewItems.length > 0) {
await Actor.pushData(callResult.previewItems);
Expand Down
16 changes: 0 additions & 16 deletions src/mcp/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,3 @@ export async function getRealActorID(actorIdOrName: string, apifyToken: string):
export async function getActorStandbyURL(realActorId: string, standbyBaseUrl = 'apify.actor'): Promise<string> {
return `https://${realActorId}.${standbyBaseUrl}`;
}

export async function getActorDefinition(actorID: string, apifyToken: string): Promise<ActorDefinition> {
const apifyClient = new ApifyClient({ token: apifyToken });
const actor = apifyClient.actor(actorID);
const defaultBuildClient = await actor.defaultBuild();
const buildInfo = await defaultBuildClient.get();
if (!buildInfo) {
throw new Error(`Default build for Actor ${actorID} not found`);
}
const { actorDefinition } = buildInfo;
if (!actorDefinition) {
throw new Error(`Actor default build ${actorID} does not have Actor definition`);
}

return actorDefinition;
}
111 changes: 99 additions & 12 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
ServerNotificationSchema,
SetLevelRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
Expand All @@ -23,9 +24,13 @@ import { type ActorCallOptions, ApifyApiError } from 'apify-client';

import log from '@apify/log';

import { ApifyClient } from '../apify-client.js';
import {
SERVER_NAME,
SERVER_VERSION,
SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION,
SKYFIRE_README_CONTENT,
SKYFIRE_TOOL_INSTRUCTIONS,
} from '../const.js';
import { prompts } from '../prompts/index.js';
import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js';
Expand All @@ -40,6 +45,14 @@ import { processParamsGetTools } from './utils.js';

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

interface ActorsMcpServerOptions {
setupSigintHandler?: boolean;
/**
* Switch to enable Skyfire agentic payment mode.
*/
skyfireMode?: boolean;
}

/**
* Create Apify MCP server
*/
Expand All @@ -49,8 +62,11 @@ export class ActorsMcpServer {
private toolsChangedHandler: ToolsChangedHandler | undefined;
private sigintHandler: (() => Promise<void>) | undefined;
private currentLogLevel = 'info';
public readonly options: ActorsMcpServerOptions;

constructor(setupSigintHandler = true) {
constructor(options: ActorsMcpServerOptions = {}) {
this.options = options;
const { setupSigintHandler = true } = options;
this.server = new Server(
{
name: SERVER_NAME,
Expand Down Expand Up @@ -161,7 +177,7 @@ export class ActorsMcpServer {
* @param toolNames - Array of tool names to ensure are loaded
* @param apifyToken - Apify API token for authentication
*/
public async loadToolsByName(toolNames: string[], apifyToken: string) {
public async loadToolsByName(toolNames: string[], apifyClient: ApifyClient) {
const loadedTools = this.listAllToolNames();
const actorsToLoad: string[] = [];
const toolsToLoad: ToolEntry[] = [];
Expand All @@ -186,7 +202,7 @@ export class ActorsMcpServer {
}

if (actorsToLoad.length > 0) {
await this.loadActorsAsTools(actorsToLoad, apifyToken);
await this.loadActorsAsTools(actorsToLoad, apifyClient);
}
}

Expand All @@ -197,8 +213,8 @@ export class ActorsMcpServer {
* @param apifyToken - Apify API token for authentication
* @returns Promise<ToolEntry[]> - Array of loaded tool entries
*/
public async loadActorsAsTools(actorIdsOrNames: string[], apifyToken: string): Promise<ToolEntry[]> {
const actorTools = await getActorsAsTools(actorIdsOrNames, apifyToken);
public async loadActorsAsTools(actorIdsOrNames: string[], apifyClient: ApifyClient): Promise<ToolEntry[]> {
const actorTools = await getActorsAsTools(actorIdsOrNames, apifyClient);
if (actorTools.length > 0) {
this.upsertTools(actorTools, true);
}
Expand All @@ -212,8 +228,8 @@ export class ActorsMcpServer {
*
* Used primarily for SSE.
*/
public async loadToolsFromUrl(url: string, apifyToken: string) {
const tools = await processParamsGetTools(url, apifyToken);
public async loadToolsFromUrl(url: string, apifyClient: ApifyClient) {
const tools = await processParamsGetTools(url, apifyClient);
if (tools.length > 0) {
log.debug('Loading tools from query parameters');
this.upsertTools(tools, false);
Expand Down Expand Up @@ -307,10 +323,44 @@ export class ActorsMcpServer {

private setupResourceHandlers(): void {
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
// No resources available, return empty response
/**
* Return the usage guide resource only if Skyfire mode is enabled. No resources otherwise for normal mode.
*/
if (this.options.skyfireMode) {
return {
resources: [
{
uri: 'file://readme.md',
name: 'readme',
description: `Apify MCP Server usage guide. Read this to understand how to use the server, especially in Skyfire mode before interacting with it.`,
mimeType: 'text/markdown',
},
],
};
}
return { resources: [] };
});

if (this.options.skyfireMode) {
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === 'file://readme.md') {
return {
contents: [{
uri: 'file://readme.md',
mimeType: 'text/markdown',
text: SKYFIRE_README_CONTENT,
}],
};
}
return {
contents: [{
uri, mimeType: 'text/plain', text: `Resource ${uri} not found`,
}],
};
});
}

this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
// No resource templates available, return empty response
return { resourceTemplates: [] };
Expand Down Expand Up @@ -368,6 +418,24 @@ export class ActorsMcpServer {
* @returns {object} - The response object containing the tools.
*/
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
/**
* Hack for the Skyfire agentic payments, we check if Skyfire mode is enabled we ad-hoc add
* the `skyfire-pay-id` input property to all Actor tools and `call-actor` tool.
*/
if (this.options.skyfireMode) {
for (const toolEntry of this.tools.values()) {
if (toolEntry.type === 'actor' || (toolEntry.type === 'internal' && toolEntry.tool.name === 'call-actor')) {
if (toolEntry.tool.inputSchema && 'properties' in toolEntry.tool.inputSchema) {
(toolEntry.tool.inputSchema.properties as Record<string, unknown>)['skyfire-pay-id'] = {
type: 'string',
description: SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION,
};
}
// Update description to include Skyfire instructions
toolEntry.tool.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`;
}
}
}
const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool.tool));
return { tools };
});
Expand All @@ -391,7 +459,7 @@ export class ActorsMcpServer {
delete request.params.userRentedActorIds;

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

// Handle actor tool
if (tool.type === 'actor') {
if (this.options.skyfireMode
&& args['skyfire-pay-id'] === undefined
) {
return {
content: [{
type: 'text',
text: SKYFIRE_TOOL_INSTRUCTIONS,
}],
};
}

const actorTool = tool.tool as ActorTool;

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

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

/**
* Create Apify token, for Skyfire mode use `skyfire-pay-id` and for normal mode use `apifyToken`.
*/
const { 'skyfire-pay-id': skyfirePayId, ...actorArgs } = args as Record<string, unknown>;
const apifyClient = this.options.skyfireMode && typeof skyfirePayId === 'string'
? new ApifyClient({ skyfirePayId })
: new ApifyClient({ token: apifyToken });

try {
log.info('Calling Actor', { actorName: actorTool.actorFullName, input: args });
log.info('Calling Actor', { actorName: actorTool.actorFullName, input: actorArgs });
const callResult = await callActorGetDataset(
actorTool.actorFullName,
args,
apifyToken as string,
actorArgs,
apifyClient,
callOptions,
progressTracker,
extra.signal,
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import { parse } from 'node:querystring';

import type { ApifyClient } from 'apify-client';

import { processInput } from '../input.js';
import type { Input } from '../types.js';
import { loadToolsFromInput } from '../utils/tools-loader.js';
Expand Down Expand Up @@ -39,9 +41,9 @@ export function getProxyMCPServerToolName(url: string, toolName: string): string
* @param url
* @param apifyToken
*/
export async function processParamsGetTools(url: string, apifyToken: string) {
export async function processParamsGetTools(url: string, apifyClient: ApifyClient) {
const input = parseInputParamsFromUrl(url);
return await loadToolsFromInput(input, apifyToken);
return await loadToolsFromInput(input, apifyClient);
}

export function parseInputParamsFromUrl(url: string): Input {
Expand Down
Loading
Loading