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
12 changes: 7 additions & 5 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 { resolveApifyClient } from '../apify-client-factory.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,13 @@ 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 = resolveApifyClient({ token: null }, { sessionId: transport.sessionId });
await mcpServer.loadToolsFromUrl(req.url, apifyClient);

transportsSSE[transport.sessionId] = transport;
mcpServers[transport.sessionId] = mcpServer;
Expand Down Expand Up @@ -152,12 +153,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 = resolveApifyClient({ token: apifyToken }, { sessionId: transport.sessionId });
await mcpServer.loadToolsFromUrl(req.url, apifyClient);

// Connect the transport to the MCP server BEFORE handling the request
await mcpServer.connect(transport);
Expand Down
100 changes: 100 additions & 0 deletions src/apify-client-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ApifyClientOptions } from 'apify';

import { ApifyClient, getApifyAPIBaseUrl } from './apify-client.js';

/**
* Context passed to getApifyClient factory. Useful for per-session overrides.
* - sessionId: a stable identifier (e.g., MCP transport session) you can use to
* memoize clients and avoid recreating them for every request.
* - headers: request-scoped headers (e.g., "skyfire-pay-id") that should be
* propagated to the Apify API calls. If provided, resolveApifyClient prefers
* these over static options to prevent header leakage across sessions.
*/
export interface ResolveClientContext {
sessionId?: string;
headers?: Record<string, string | number | boolean | undefined>;
}

/**
* Options for resolving an ApifyClient. You can:
* - Inject an already constructed client via `apifyClient`.
* - Provide a factory `getApifyClient(ctx)` to build per-session clients.
* - Or let the helper construct a client from `token`/`baseUrl`/`skyfirePayId`.
*
* Precedence (highest to lowest): getApifyClient(ctx) -> apifyClient -> construct from options/env.
*
* Notes
* - token: If omitted, resolveApifyClient falls back to process.env.APIFY_TOKEN.
* - baseUrl: If omitted, uses getApifyAPIBaseUrl() which respects APIFY_API_BASE_URL
* and special AT_HOME handling.
* - skyfirePayId: Forwarded to our ApifyClient wrapper which adds an interceptor to
* set the "skyfire-pay-id" HTTP header. When a header is present in ctx.headers,
* it overrides this option for the current resolution.
*/
export interface ResolveClientOptions extends Omit<ApifyClientOptions, 'token' | 'baseUrl'> {
// Convenience auth/config
token?: string | null | undefined;
baseUrl?: string;
skyfirePayId?: string;
// Direct injection or factory
apifyClient?: ApifyClient;
getApifyClient?: (ctx?: ResolveClientContext) => ApifyClient;
}

/**
* Resolve an ApifyClient instance from multiple inputs in a consistent order:
* 1) If getApifyClient provided, call it with the context.
* 2) Else if apifyClient provided, return it as-is.
* 3) Else construct a new ApifyClient using provided token/baseUrl/skyfirePayId/options.
* - baseUrl falls back to getApifyAPIBaseUrl().
*
* Examples
* --------
* 1) Simplest: use env APIFY_TOKEN
* const client = resolveApifyClient();
*
* 2) Pass a token explicitly
* const client = resolveApifyClient({ token: 'apify-XXX' });
*
* 3) Inject a prebuilt client (useful for tests or custom interceptors)
* const injected = new ApifyClient({ token: 'apify-XXX' });
* const client = resolveApifyClient({ apifyClient: injected });
*
* 4) Use a factory to provide per-session clients and headers
* const clients = new Map<string, ApifyClient>();
* function getApifyClient(ctx?: ResolveClientContext) {
* const id = ctx?.sessionId ?? 'default';
* const skyfire = (ctx?.headers?.['skyfire-pay-id'] as string | undefined) ?? 'global-skyfire';
* let c = clients.get(id);
* if (!c) {
* c = new ApifyClient({ token: process.env.APIFY_TOKEN, skyfirePayId: skyfire });
* clients.set(id, c);
* }
* return c;
* }
* const client = resolveApifyClient({ getApifyClient }, { sessionId: 's1', headers: { 'skyfire-pay-id': 'per-session' } });
*
* 5) Change base URL (e.g., staging) and unauthenticated use
* const client = resolveApifyClient({ baseUrl: 'https://api.staging.apify.com', token: null });
*/
export function resolveApifyClient(options: ResolveClientOptions = {}, ctx?: ResolveClientContext): ApifyClient {
if (typeof options.getApifyClient === 'function') {
return options.getApifyClient(ctx);
}
if (options.apifyClient) {
return options.apifyClient;
}

const { token, baseUrl, skyfirePayId, getApifyClient: _ignored, apifyClient: _ignored2, ...rest } = options;

// If ctx carries a skyfire-pay-id header, prefer it over provided option to support per-session overrides
const headerSkyfire = ctx?.headers?.['skyfire-pay-id'] as string | undefined;

return new ApifyClient({
...(rest as ApifyClientOptions),
token: token ?? process.env.APIFY_TOKEN,
baseUrl: baseUrl ?? getApifyAPIBaseUrl(),
// Our ApifyClient wrapper supports this custom option to inject header via interceptor
skyfirePayId: headerSkyfire ?? skyfirePayId,
} as unknown as ApifyClientOptions & { token?: string | null; skyfirePayId?: string });
}
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 };
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;
}
Loading