diff --git a/src/actor/server.ts b/src/actor/server.ts index b1c5224..90c9918 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -12,6 +12,7 @@ import express from 'express'; import log from '@apify/log'; import { ActorsMcpServer } from '../mcp/server.js'; +import type { AuthInfo } from '../types.js'; import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js'; import { getActorRunData } from './utils.js'; @@ -73,9 +74,12 @@ export function createExpressApp( 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 authInfo: AuthInfo = { + value: process.env.APIFY_TOKEN as string, + type: 'apify', + }; + log.debug('Loading tools from URL', { sessionId: transport.sessionId, tr: TransportType.HTTP }); + await mcpServer.loadToolsFromUrl(req.url, authInfo); transportsSSE[transport.sessionId] = transport; mcpServers[transport.sessionId] = mcpServer; @@ -155,9 +159,12 @@ export function createExpressApp( const mcpServer = new ActorsMcpServer(false); // Load MCP server tools - const apifyToken = process.env.APIFY_TOKEN as string; + const authInfo: AuthInfo = { + value: process.env.APIFY_TOKEN as string, + type: 'apify', + }; log.debug('Loading tools from URL', { sessionId: transport.sessionId, tr: TransportType.HTTP }); - await mcpServer.loadToolsFromUrl(req.url, apifyToken); + await mcpServer.loadToolsFromUrl(req.url, authInfo); // Connect the transport to the MCP server BEFORE handling the request await mcpServer.connect(transport); diff --git a/src/apify-client.ts b/src/apify-client.ts index 026ba79..4ff525e 100644 --- a/src/apify-client.ts +++ b/src/apify-client.ts @@ -3,6 +3,7 @@ import { ApifyClient as _ApifyClient } from 'apify-client'; import type { AxiosRequestConfig } from 'axios'; import { USER_AGENT_ORIGIN } from './const.js'; +import type { AuthInfo } from './types.js'; /** * Adds a User-Agent header to the request config. @@ -22,23 +23,56 @@ export function getApifyAPIBaseUrl(): string { return process.env.APIFY_API_BASE_URL || 'https://api.apify.com'; } +/** + * Adds Skyfire header to the request config if needed. + * @param config + * @param authInfo + * @private + */ +function addSkyfireHeader(config: AxiosRequestConfig, authInfo: AuthInfo): AxiosRequestConfig { + const updatedConfig = { ...config }; + updatedConfig.headers = updatedConfig.headers ?? {}; + updatedConfig.headers['skyfire-pay-id'] = authInfo.value; + return updatedConfig; +} + export class ApifyClient extends _ApifyClient { - constructor(options: ApifyClientOptions) { + constructor(options: ApifyClientOptions & { authInfo?: AuthInfo }) { + // Destructure to separate authInfo from other options + const { authInfo, ...clientOptions } = options; + /** * 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') { - // eslint-disable-next-line no-param-reassign - delete options.token; + if (clientOptions.token?.toLowerCase() === 'your-apify-token') { + delete clientOptions.token; + } + + // Handle authInfo if provided + if (authInfo) { + if (authInfo.type === 'skyfire') { + // For Skyfire tokens: DO NOT set as bearer token + // Only add the skyfire-pay-id header via request interceptor + // Remove any existing token to ensure no bearer auth + delete clientOptions.token; + } else { + // For Apify tokens: Use as regular bearer token (existing behavior) + clientOptions.token = authInfo.value; + } + } + + const requestInterceptors = [addUserAgent]; + if (authInfo?.type === 'skyfire') { + requestInterceptors.push((config) => addSkyfireHeader(config, authInfo)); } super({ - ...options, + ...clientOptions, // safe to spread without authInfo baseUrl: getApifyAPIBaseUrl(), - requestInterceptors: [addUserAgent], + requestInterceptors, }); } } diff --git a/src/main.ts b/src/main.ts index 0d62051..490b112 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ import log from '@apify/log'; import { createExpressApp } from './actor/server.js'; import { processInput } from './input.js'; import { callActorGetDataset } from './tools/index.js'; -import type { Input } from './types.js'; +import type { AuthInfo, Input } from './types.js'; const STANDBY_MODE = Actor.getEnv().metaOrigin === 'STANDBY'; @@ -25,6 +25,11 @@ if (!process.env.APIFY_TOKEN) { process.exit(1); } +const authInfo: AuthInfo = { + value: process.env.APIFY_TOKEN, + type: 'apify', +}; + const input = processInput((await Actor.getInput>()) ?? ({} as Input)); log.info('Loaded input', { input: JSON.stringify(input) }); @@ -44,7 +49,7 @@ 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 { items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, process.env.APIFY_TOKEN, options); + const { items } = await callActorGetDataset(input.debugActor!, input.debugActorInput!, authInfo, options); await Actor.pushData(items); log.info('Pushed items to dataset', { itemCount: items.count }); diff --git a/src/mcp/actors.ts b/src/mcp/actors.ts index 0185978..6b2d368 100644 --- a/src/mcp/actors.ts +++ b/src/mcp/actors.ts @@ -1,6 +1,5 @@ import type { ActorDefinition } from 'apify-client'; -import { ApifyClient } from '../apify-client.js'; import { MCP_STREAMABLE_ENDPOINT } from '../const.js'; import type { ActorDefinitionPruned } from '../types.js'; @@ -44,38 +43,8 @@ export async function getActorMCPServerURL(realActorId: string, mcpServerPath: s } /** -* Gets Actor ID from the Actor object. -*/ -export async function getRealActorID(actorIdOrName: string, apifyToken: string): Promise { - const apifyClient = new ApifyClient({ token: apifyToken }); - - const actor = apifyClient.actor(actorIdOrName); - const info = await actor.get(); - if (!info) { - throw new Error(`Actor ${actorIdOrName} not found`); - } - return info.id; -} - -/** -* Returns standby URL for given Actor ID. -*/ + * Returns standby URL for given Actor ID. + */ export async function getActorStandbyURL(realActorId: string, standbyBaseUrl = 'apify.actor'): Promise { return `https://${realActorId}.${standbyBaseUrl}`; } - -export async function getActorDefinition(actorID: string, apifyToken: string): Promise { - 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; -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1aacabc..72e2ae3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -30,7 +30,7 @@ import { import { prompts } from '../prompts/index.js'; import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js'; import { decodeDotPropertyNames } from '../tools/utils.js'; -import type { ActorMcpTool, ActorTool, HelperTool, ToolEntry } from '../types.js'; +import type { ActorMcpTool, ActorTool, AuthInfo, HelperTool, ToolEntry } from '../types.js'; import { createProgressTracker } from '../utils/progress.js'; import { getToolPublicFieldOnly } from '../utils/tools.js'; import { connectMCPClient } from './client.js'; @@ -155,12 +155,12 @@ export class ActorsMcpServer { } /** - * Loads missing toolNames from a provided list of tool names. - * Skips toolNames that are already loaded and loads only the missing ones. - * @param toolNames - Array of tool names to ensure are loaded - * @param apifyToken - Apify API token for authentication - */ - public async loadToolsByName(toolNames: string[], apifyToken: string) { + * Loads missing toolNames from a provided list of tool names. + * Skips toolNames that are already loaded and loads only the missing ones. + * @param toolNames - Array of tool names to ensure are loaded + * @param authInfo - Info for Apify service authentication + */ + public async loadToolsByName(toolNames: string[], authInfo: AuthInfo) { const loadedTools = this.listAllToolNames(); const actorsToLoad: string[] = []; const toolsToLoad: ToolEntry[] = []; @@ -185,7 +185,7 @@ export class ActorsMcpServer { } if (actorsToLoad.length > 0) { - await this.loadActorsAsTools(actorsToLoad, apifyToken); + await this.loadActorsAsTools(actorsToLoad, authInfo); } } @@ -193,11 +193,11 @@ export class ActorsMcpServer { * Load actors as tools, upsert them to the server, and return the tool entries. * This is a public method that wraps getActorsAsTools and handles the upsert operation. * @param actorIdsOrNames - Array of actor IDs or names to load as tools - * @param apifyToken - Apify API token for authentication + * @param authInfo - Info for Apify service authentication * @returns Promise - Array of loaded tool entries */ - public async loadActorsAsTools(actorIdsOrNames: string[], apifyToken: string): Promise { - const actorTools = await getActorsAsTools(actorIdsOrNames, apifyToken); + public async loadActorsAsTools(actorIdsOrNames: string[], authInfo: AuthInfo): Promise { + const actorTools = await getActorsAsTools(actorIdsOrNames, authInfo); if (actorTools.length > 0) { this.upsertTools(actorTools, true); } @@ -211,8 +211,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, authInfo: AuthInfo) { + const tools = await processParamsGetTools(url, authInfo); if (tools.length > 0) { log.debug('Loading tools from query parameters'); this.upsertTools(tools, false); @@ -381,17 +381,28 @@ export class ActorsMcpServer { // eslint-disable-next-line prefer-const let { name, arguments: args, _meta: meta } = request.params; const { progressToken } = meta || {}; - const apifyToken = (request.params.apifyToken || process.env.APIFY_TOKEN) as string; + // Extract auth info with fallback to APIFY_TOKEN environment variable + let authInfo: AuthInfo | undefined = request.params.authInfo as AuthInfo; + + // Fallback to APIFY_TOKEN environment variable for local development + if (!authInfo && process.env.APIFY_TOKEN) { + authInfo = { + value: process.env.APIFY_TOKEN, + type: 'apify', // Environment variable is always an Apify token + }; + } const userRentedActorIds = request.params.userRentedActorIds as string[] | undefined; - // Remove apifyToken from request.params just in case - delete request.params.apifyToken; + // Remove authInfo from request.params only if it was provided in params + if (request.params.authInfo) { + delete request.params.authInfo; + } // Remove other custom params passed from apify-mcp-server delete request.params.userRentedActorIds; - // Validate token - if (!apifyToken) { - const msg = 'APIFY_TOKEN is required. It must be set in the environment variables or passed as a parameter in the body.'; + // Validate auth info + if (!authInfo || !authInfo.value) { + const msg = `Valid authentication token required. It must be provided either in the Bearer Authorization header, APIFY_TOKEN environment variable or skyfire-pay-id header as Skyfire payment token.`; log.error(msg); await this.server.sendLoggingMessage({ level: 'error', data: msg }); throw new McpError( @@ -462,7 +473,7 @@ export class ActorsMcpServer { extra, apifyMcpServer: this, mcpServer: this.server, - apifyToken, + authInfo, userRentedActorIds, progressTracker, }) as object; @@ -478,7 +489,7 @@ export class ActorsMcpServer { const serverTool = tool.tool as ActorMcpTool; let client: Client | undefined; try { - client = await connectMCPClient(serverTool.serverUrl, apifyToken); + client = await connectMCPClient(serverTool.serverUrl, authInfo.value); // Only set up notification handlers if progressToken is provided by the client if (progressToken) { @@ -527,7 +538,7 @@ export class ActorsMcpServer { const { runId, datasetId, items } = await callActorGetDataset( actorTool.actorFullName, args, - apifyToken as string, + authInfo, callOptions, progressTracker, ); diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 8c682c0..d816b3b 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { parse } from 'node:querystring'; import { processInput } from '../input.js'; -import type { Input } from '../types.js'; +import type { AuthInfo, Input } from '../types.js'; import { loadToolsFromInput } from '../utils/tools-loader.js'; import { MAX_TOOL_NAME_LENGTH, SERVER_ID_LENGTH } from './const.js'; @@ -37,11 +37,11 @@ export function getProxyMCPServerToolName(url: string, toolName: string): string * Process input parameters from URL and get tools * If URL contains query parameter `actors`, return tools from Actors otherwise return null. * @param url - * @param apifyToken + * @param authInfo */ -export async function processParamsGetTools(url: string, apifyToken: string) { +export async function processParamsGetTools(url: string, authInfo: AuthInfo) { const input = parseInputParamsFromUrl(url); - return await loadToolsFromInput(input, apifyToken); + return await loadToolsFromInput(input, authInfo); } export function parseInputParamsFromUrl(url: string): Input { diff --git a/src/stdio.ts b/src/stdio.ts index 39bb309..ea5ebd0 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -24,7 +24,7 @@ import log from '@apify/log'; import { processInput } from './input.js'; import { ActorsMcpServer } from './mcp/server.js'; -import type { Input, ToolSelector } from './types.js'; +import type { AuthInfo, Input, ToolSelector } from './types.js'; import { loadToolsFromInput } from './utils/tools-loader.js'; // Keeping this interface here and not types.ts since @@ -122,7 +122,11 @@ async function main() { const normalized = processInput(input); // Use the shared tools loading logic - const tools = await loadToolsFromInput(normalized, process.env.APIFY_TOKEN as string); + const authInfo: AuthInfo = { + value: process.env.APIFY_TOKEN as string, + type: 'apify', + }; + const tools = await loadToolsFromInput(normalized, authInfo); mcpServer.upsertTools(tools); diff --git a/src/tools/actor.ts b/src/tools/actor.ts index c3f1a7e..a9260ba 100644 --- a/src/tools/actor.ts +++ b/src/tools/actor.ts @@ -16,7 +16,7 @@ import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js'; import { connectMCPClient } from '../mcp/client.js'; import { getMCPServerTools } from '../mcp/proxy.js'; import { actorDefinitionPrunedCache } from '../state.js'; -import type { ActorDefinitionStorage, ActorInfo, ToolEntry } from '../types.js'; +import type { ActorDefinitionStorage, ActorInfo, AuthInfo, ToolEntry } from '../types.js'; import { getActorDefinitionStorageFieldNames } from '../utils/actor.js'; import { fetchActorDetails } from '../utils/actor-details.js'; import { getValuesByDotKeys } from '../utils/generic.js'; @@ -37,26 +37,26 @@ export type CallActorGetDatasetResult = { * Calls an Apify actor and retrieves the dataset items. * * - * It requires the `APIFY_TOKEN` environment variable to be set. + * It requires authentication token to be provided. * If the `APIFY_IS_AT_HOME` the dataset items are pushed to the Apify dataset. * * @param {string} actorName - The name of the actor to call. * @param {ActorCallOptions} callOptions - The options to pass to the actor. * @param {unknown} input - The input to pass to the actor. - * @param {string} apifyToken - The Apify token to use for authentication. + * @param {AuthInfo} authInfo - The authentication info to use. * @param {ProgressTracker} progressTracker - Optional progress tracker for real-time updates. * @returns {Promise<{ actorRun: any, items: object[] }>} - A promise that resolves to an object containing the actor run and dataset items. - * @throws {Error} - Throws an error if the `APIFY_TOKEN` is not set + * @throws {Error} - Throws an error if the authentication token is not valid */ export async function callActorGetDataset( actorName: string, input: unknown, - apifyToken: string, + authInfo: AuthInfo, callOptions: ActorCallOptions | undefined = undefined, progressTracker?: ProgressTracker | null, ): Promise { try { - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const actorClient = client.actor(actorName); // Start the actor run but don't wait for completion @@ -64,7 +64,7 @@ export async function callActorGetDataset( // Start progress tracking if tracker is provided if (progressTracker) { - progressTracker.startActorRunUpdates(actorRun.id, apifyToken, actorName); + progressTracker.startActorRunUpdates(actorRun.id, authInfo, actorName); } // Wait for the actor to complete @@ -162,7 +162,7 @@ Instructions: ${ACTOR_ADDITIONAL_INSTRUCTIONS}`, async function getMCPServersAsTools( actorsInfo: ActorInfo[], - apifyToken: string, + authInfo: AuthInfo, ): Promise { const actorsMCPServerTools: ToolEntry[] = []; for (const actorInfo of actorsInfo) { @@ -186,7 +186,7 @@ async function getMCPServersAsTools( let client: Client | undefined; try { - client = await connectMCPClient(mcpServerUrl, apifyToken); + client = await connectMCPClient(mcpServerUrl, authInfo.value); const serverTools = await getMCPServerTools(actorId, client, mcpServerUrl); actorsMCPServerTools.push(...serverTools); } finally { @@ -199,7 +199,7 @@ async function getMCPServersAsTools( export async function getActorsAsTools( actorIdsOrNames: string[], - apifyToken: string, + authInfo: AuthInfo, ): Promise { log.debug('Fetching actors as tools', { actorNames: actorIdsOrNames }); @@ -214,7 +214,7 @@ export async function getActorsAsTools( } as ActorInfo; } - const actorDefinitionPruned = await getActorDefinition(actorIdOrName, apifyToken); + const actorDefinitionPruned = await getActorDefinition(actorIdOrName, authInfo); if (!actorDefinitionPruned) { log.error('Actor not found or definition is not available', { actorName: actorIdOrName }); return null; @@ -236,7 +236,7 @@ export async function getActorsAsTools( const [normalTools, mcpServerTools] = await Promise.all([ getNormalActorsAsTools(normalActorsInfo), - getMCPServersAsTools(actorMCPServersInfo, apifyToken), + getMCPServersAsTools(actorMCPServersInfo, authInfo), ]); return [...normalTools, ...mcpServerTools]; @@ -293,13 +293,13 @@ The step parameter enforces this workflow - you cannot call an Actor without fir inputSchema: zodToJsonSchema(callActorArgs), ajvValidate: ajv.compile(zodToJsonSchema(callActorArgs)), call: async (toolArgs) => { - const { args, apifyToken, progressTracker } = toolArgs; + const { args, authInfo, progressTracker } = toolArgs; const { actor: actorName, step, input, callOptions } = callActorArgs.parse(args); try { if (step === 'info') { // Step 1: Return actor card and schema directly - const details = await fetchActorDetails(apifyToken, actorName); + const details = await fetchActorDetails(authInfo, actorName); if (!details) { return { content: [{ type: 'text', text: `Actor information for '${actorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }], @@ -320,7 +320,7 @@ The step parameter enforces this workflow - you cannot call an Actor without fir }; } - const [actor] = await getActorsAsTools([actorName], apifyToken); + const [actor] = await getActorsAsTools([actorName], authInfo); if (!actor) { return { @@ -345,7 +345,7 @@ The step parameter enforces this workflow - you cannot call an Actor without fir const { runId, datasetId, items } = await callActorGetDataset( actorName, input, - apifyToken, + authInfo, callOptions, progressTracker, ); diff --git a/src/tools/build.ts b/src/tools/build.ts index 979c43a..fb00faf 100644 --- a/src/tools/build.ts +++ b/src/tools/build.ts @@ -9,6 +9,7 @@ import { ACTOR_README_MAX_LENGTH, HelperTools } from '../const.js'; import type { ActorDefinitionPruned, ActorDefinitionWithDesc, + AuthInfo, InternalTool, ISchemaProperties, ToolEntry, @@ -22,16 +23,16 @@ const ajv = new Ajv({ coerceTypes: 'array', strict: false }); * First, fetch the Actor details to get the default build tag and buildId. * Then, fetch the build details and return actorName, description, and input schema. * @param {string} actorIdOrName - Actor ID or Actor full name. + * @param {AuthInfo} authInfo - Authentication info. * @param {number} limit - Truncate the README to this limit. - * @param {string} apifyToken * @returns {Promise} - The actor definition with description or null if not found. */ export async function getActorDefinition( actorIdOrName: string, - apifyToken: string, + authInfo: AuthInfo, limit: number = ACTOR_README_MAX_LENGTH, ): Promise { - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const actorClient = client.actor(actorIdOrName); try { // Fetch actor details @@ -123,10 +124,10 @@ export const actorDefinitionTool: ToolEntry = { inputSchema: zodToJsonSchema(getActorDefinitionArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(getActorDefinitionArgsSchema)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getActorDefinitionArgsSchema.parse(args); - const v = await getActorDefinition(parsed.actorName, apifyToken, parsed.limit); + const v = await getActorDefinition(parsed.actorName, authInfo, parsed.limit); if (!v) { return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] }; } diff --git a/src/tools/dataset.ts b/src/tools/dataset.ts index 3f73386..6880577 100644 --- a/src/tools/dataset.ts +++ b/src/tools/dataset.ts @@ -55,9 +55,9 @@ export const getDataset: ToolEntry = { inputSchema: zodToJsonSchema(getDatasetArgs), ajvValidate: ajv.compile(zodToJsonSchema(getDatasetArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getDatasetArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const v = await client.dataset(parsed.datasetId).get(); if (!v) { return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] }; @@ -88,9 +88,9 @@ export const getDatasetItems: ToolEntry = { inputSchema: zodToJsonSchema(getDatasetItemsArgs), ajvValidate: ajv.compile(zodToJsonSchema(getDatasetItemsArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getDatasetItemsArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); // Convert comma-separated strings to arrays const fields = parsed.fields?.split(',').map((f) => f.trim()); @@ -174,9 +174,9 @@ export const getDatasetSchema: ToolEntry = { inputSchema: zodToJsonSchema(getDatasetSchemaArgs), ajvValidate: ajv.compile(zodToJsonSchema(getDatasetSchemaArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getDatasetSchemaArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); // Get dataset items const datasetResponse = await client.dataset(parsed.datasetId).listItems({ diff --git a/src/tools/dataset_collection.ts b/src/tools/dataset_collection.ts index 08a7956..d338d97 100644 --- a/src/tools/dataset_collection.ts +++ b/src/tools/dataset_collection.ts @@ -41,9 +41,9 @@ export const getUserDatasetsList: ToolEntry = { inputSchema: zodToJsonSchema(getUserDatasetsListArgs), ajvValidate: ajv.compile(zodToJsonSchema(getUserDatasetsListArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getUserDatasetsListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const datasets = await client.datasets().list({ limit: parsed.limit, offset: parsed.offset, diff --git a/src/tools/fetch-actor-details.ts b/src/tools/fetch-actor-details.ts index ef3dbd7..6fe1848 100644 --- a/src/tools/fetch-actor-details.ts +++ b/src/tools/fetch-actor-details.ts @@ -28,9 +28,9 @@ export const fetchActorDetailsTool: ToolEntry = { inputSchema: zodToJsonSchema(fetchActorDetailsToolArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(fetchActorDetailsToolArgsSchema)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = fetchActorDetailsToolArgsSchema.parse(args); - const details = await fetchActorDetails(apifyToken, parsed.actor); + const details = await fetchActorDetails(authInfo, parsed.actor); if (!details) { return { content: [{ type: 'text', text: `Actor information for '${parsed.actor}' was not found. Please check the Actor ID or name and ensure the Actor exists.` }], diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index cd77502..6990aaa 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -48,7 +48,7 @@ export const addTool: ToolEntry = { ajvValidate: ajv.compile(zodToJsonSchema(addToolArgsSchema)), // TODO: I don't like that we are passing apifyMcpServer and mcpServer to the tool call: async (toolArgs) => { - const { apifyMcpServer, apifyToken, args, extra: { sendNotification } } = toolArgs; + const { apifyMcpServer, authInfo, args, extra: { sendNotification } } = toolArgs; const parsed = addToolArgsSchema.parse(args); if (apifyMcpServer.listAllToolNames().includes(parsed.actor)) { return { @@ -59,7 +59,7 @@ export const addTool: ToolEntry = { }; } - const tools = await apifyMcpServer.loadActorsAsTools([parsed.actor], apifyToken); + const tools = await apifyMcpServer.loadActorsAsTools([parsed.actor], authInfo); /** * If no tools were found, return a message that the Actor was not found * instead of returning that non existent tool was added since the diff --git a/src/tools/key_value_store.ts b/src/tools/key_value_store.ts index 9433089..6c55d7a 100644 --- a/src/tools/key_value_store.ts +++ b/src/tools/key_value_store.ts @@ -28,9 +28,9 @@ export const getKeyValueStore: ToolEntry = { inputSchema: zodToJsonSchema(getKeyValueStoreArgs), ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getKeyValueStoreArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const store = await client.keyValueStore(parsed.storeId).get(); return { content: [{ type: 'text', text: JSON.stringify(store) }] }; }, @@ -65,9 +65,9 @@ export const getKeyValueStoreKeys: ToolEntry = { inputSchema: zodToJsonSchema(getKeyValueStoreKeysArgs), ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreKeysArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getKeyValueStoreKeysArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const keys = await client.keyValueStore(parsed.storeId).listKeys({ exclusiveStartKey: parsed.exclusiveStartKey, limit: parsed.limit, @@ -102,9 +102,9 @@ export const getKeyValueStoreRecord: ToolEntry = { inputSchema: zodToJsonSchema(getKeyValueStoreRecordArgs), ajvValidate: ajv.compile(zodToJsonSchema(getKeyValueStoreRecordArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getKeyValueStoreRecordArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const record = await client.keyValueStore(parsed.storeId).getRecord(parsed.recordKey); return { content: [{ type: 'text', text: JSON.stringify(record) }] }; }, diff --git a/src/tools/key_value_store_collection.ts b/src/tools/key_value_store_collection.ts index a661b2b..22705d1 100644 --- a/src/tools/key_value_store_collection.ts +++ b/src/tools/key_value_store_collection.ts @@ -41,9 +41,9 @@ export const getUserKeyValueStoresList: ToolEntry = { inputSchema: zodToJsonSchema(getUserKeyValueStoresListArgs), ajvValidate: ajv.compile(zodToJsonSchema(getUserKeyValueStoresListArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getUserKeyValueStoresListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const stores = await client.keyValueStores().list({ limit: parsed.limit, offset: parsed.offset, diff --git a/src/tools/run.ts b/src/tools/run.ts index 5800500..38d1b7d 100644 --- a/src/tools/run.ts +++ b/src/tools/run.ts @@ -35,9 +35,9 @@ export const getActorRun: ToolEntry = { inputSchema: zodToJsonSchema(getActorRunArgs), ajvValidate: ajv.compile(zodToJsonSchema(getActorRunArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getActorRunArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const v = await client.run(parsed.runId).get(); if (!v) { return { content: [{ type: 'text', text: `Run with ID '${parsed.runId}' not found.` }] }; @@ -69,9 +69,9 @@ export const getActorRunLog: ToolEntry = { inputSchema: zodToJsonSchema(GetRunLogArgs), ajvValidate: ajv.compile(zodToJsonSchema(GetRunLogArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = GetRunLogArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const v = await client.run(parsed.runId).log().get() ?? ''; const lines = v.split('\n'); const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n'); @@ -94,9 +94,9 @@ export const abortActorRun: ToolEntry = { inputSchema: zodToJsonSchema(abortRunArgs), ajvValidate: ajv.compile(zodToJsonSchema(abortRunArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = abortRunArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const v = await client.run(parsed.runId).abort({ gracefully: parsed.gracefully }); return { content: [{ type: 'text', text: JSON.stringify(v) }] }; }, diff --git a/src/tools/run_collection.ts b/src/tools/run_collection.ts index ff4de21..e1ac70e 100644 --- a/src/tools/run_collection.ts +++ b/src/tools/run_collection.ts @@ -38,9 +38,9 @@ export const getUserRunsList: ToolEntry = { inputSchema: zodToJsonSchema(getUserRunsListArgs), ajvValidate: ajv.compile(zodToJsonSchema(getUserRunsListArgs)), call: async (toolArgs) => { - const { args, apifyToken } = toolArgs; + const { args, authInfo } = toolArgs; const parsed = getUserRunsListArgs.parse(args); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const runs = await client.runs().list({ limit: parsed.limit, offset: parsed.offset, desc: parsed.desc, status: parsed.status }); return { content: [{ type: 'text', text: JSON.stringify(runs) }] }; }, diff --git a/src/tools/store_collection.ts b/src/tools/store_collection.ts index 7446cdd..a22aa05 100644 --- a/src/tools/store_collection.ts +++ b/src/tools/store_collection.ts @@ -5,16 +5,16 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { ApifyClient } from '../apify-client.js'; import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js'; -import type { ActorPricingModel, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js'; +import type { ActorPricingModel, AuthInfo, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js'; import { formatActorsListToActorCard } from '../utils/actor-card.js'; export async function searchActorsByKeywords( search: string, - apifyToken: string, + authInfo: AuthInfo, limit: number | undefined = undefined, offset: number | undefined = undefined, ): Promise { - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); const results = await client.store().list({ search, limit, offset }); return results.items; } @@ -90,11 +90,11 @@ export const searchActors: ToolEntry = { inputSchema: zodToJsonSchema(searchActorsArgsSchema), ajvValidate: ajv.compile(zodToJsonSchema(searchActorsArgsSchema)), call: async (toolArgs) => { - const { args, apifyToken, userRentedActorIds } = toolArgs; + const { args, authInfo, userRentedActorIds } = toolArgs; const parsed = searchActorsArgsSchema.parse(args); let actors = await searchActorsByKeywords( parsed.search, - apifyToken, + authInfo, parsed.limit + ACTOR_SEARCH_ABOVE_LIMIT, parsed.offset, ); diff --git a/src/types.ts b/src/types.ts index c4c5e2e..b759ecb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,21 @@ export interface ActorTool extends ToolBase { memoryMbytes?: number; } +export type AuthInfoType = 'apify' | 'skyfire'; + +/** + * Interface representing an authentication info used throughout the MCP server. + * This info is used to authenticate API calls to Apify services. + */ +export interface AuthInfo { + /** The actual token string value used for authentication */ + value: string; + /** The type of authentication info, determining how it's processed */ + type: AuthInfoType; + /** Optional user ID associated with the info, typically populated for 'apify' type info via IAM validation */ + userId?: string; +} + /** * Arguments passed to internal tool calls. * Contains both the tool arguments and server references. @@ -102,8 +117,8 @@ export type InternalToolArgs = { apifyMcpServer: ActorsMcpServer; /** Reference to the MCP server instance */ mcpServer: Server; - /** Apify API token */ - apifyToken: string; + /** Authentication info containing value, type, and optional userId */ + authInfo: AuthInfo; /** List of Actor IDs that the user has rented */ userRentedActorIds?: string[]; /** Optional progress tracker for long running internal tools, like call-actor */ diff --git a/src/utils/actor-details.ts b/src/utils/actor-details.ts index 494db6f..8ab3c08 100644 --- a/src/utils/actor-details.ts +++ b/src/utils/actor-details.ts @@ -2,7 +2,7 @@ import type { Actor, Build } from 'apify-client'; import { ApifyClient } from '../apify-client.js'; import { filterSchemaProperties, shortenProperties } from '../tools/utils.js'; -import type { IActorInputSchema } from '../types.js'; +import type { AuthInfo, IActorInputSchema } from '../types.js'; import { formatActorToActorCard } from './actor-card.js'; // Keep the interface here since it is a self contained module @@ -14,8 +14,8 @@ export interface ActorDetailsResult { readme: string; } -export async function fetchActorDetails(apifyToken: string, actorName: string): Promise { - const client = new ApifyClient({ token: apifyToken }); +export async function fetchActorDetails(authInfo: AuthInfo, actorName: string): Promise { + const client = new ApifyClient({ authInfo }); const [actorInfo, buildInfo]: [Actor | undefined, Build | undefined] = await Promise.all([ client.actor(actorName).get(), client.actor(actorName).defaultBuild().then(async (build) => build.get()), diff --git a/src/utils/progress.ts b/src/utils/progress.ts index 385c90f..52eea40 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -2,6 +2,7 @@ import type { ProgressNotification } from '@modelcontextprotocol/sdk/types.js'; import { ApifyClient } from '../apify-client.js'; import { PROGRESS_NOTIFICATION_INTERVAL_MS } from '../const.js'; +import type { AuthInfo } from '../types.js'; export class ProgressTracker { private progressToken: string | number; @@ -36,9 +37,9 @@ export class ProgressTracker { } } - startActorRunUpdates(runId: string, apifyToken: string, actorName: string): void { + startActorRunUpdates(runId: string, authInfo: AuthInfo, actorName: string): void { this.stop(); - const client = new ApifyClient({ token: apifyToken }); + const client = new ApifyClient({ authInfo }); let lastStatus = ''; let lastStatusMessage = ''; diff --git a/src/utils/tools-loader.ts b/src/utils/tools-loader.ts index ab9c99c..e64c3a2 100644 --- a/src/utils/tools-loader.ts +++ b/src/utils/tools-loader.ts @@ -6,7 +6,7 @@ import { defaults } from '../const.js'; import { addTool } from '../tools/helpers.js'; import { getActorsAsTools, toolCategories, toolCategoriesEnabledByDefault } from '../tools/index.js'; -import type { Input, ToolCategory, ToolEntry } from '../types.js'; +import type { AuthInfo, Input, ToolCategory, ToolEntry } from '../types.js'; import { getExpectedToolsByCategories } from './tools.js'; // Lazily-computed cache of internal tools by name to avoid circular init issues. @@ -26,12 +26,12 @@ function getInternalToolByNameMap(): Map { * This function is used by both the stdio.ts and the processParamsGetTools function. * * @param input The processed Input object - * @param apifyToken The Apify API token + * @param authInfo The authentication info * @returns An array of tool entries */ export async function loadToolsFromInput( input: Input, - apifyToken: string, + authInfo: AuthInfo, ): Promise { // Helpers for readability const normalizeSelectors = (value: Input['tools']): (string | ToolCategory)[] | undefined => { @@ -106,7 +106,7 @@ export async function loadToolsFromInput( // Actor tools (if any) if (actorNamesToLoad.length > 0) { - const actorTools = await getActorsAsTools(actorNamesToLoad, apifyToken); + const actorTools = await getActorsAsTools(actorNamesToLoad, authInfo); result.push(...actorTools); } diff --git a/tests/integration/actor.server-sse.test.ts b/tests/integration/actor.server-sse.test.ts index 6142cfc..a75408d 100644 --- a/tests/integration/actor.server-sse.test.ts +++ b/tests/integration/actor.server-sse.test.ts @@ -7,12 +7,13 @@ import log from '@apify/log'; import { createExpressApp } from '../../src/actor/server.js'; import { createMcpSseClient } from '../helpers.js'; import { createIntegrationTestsSuite } from './suite.js'; +import { getAvailablePort } from './utils/port.js'; let app: Express; let httpServer: HttpServer; -const httpServerPort = 50000; -const httpServerHost = `http://localhost:${httpServerPort}`; -const mcpUrl = `${httpServerHost}/sse`; +let httpServerPort: number; +let httpServerHost: string; +let mcpUrl: string; createIntegrationTestsSuite({ suiteName: 'Apify MCP Server SSE', @@ -21,6 +22,11 @@ createIntegrationTestsSuite({ beforeAllFn: async () => { log.setLevel(log.LEVELS.OFF); + // Get an available port + httpServerPort = await getAvailablePort(); + httpServerHost = `http://localhost:${httpServerPort}`; + mcpUrl = `${httpServerHost}/sse`; + // Create an express app app = createExpressApp(httpServerHost); diff --git a/tests/integration/actor.server-streamable.test.ts b/tests/integration/actor.server-streamable.test.ts index 56aa522..c21923b 100644 --- a/tests/integration/actor.server-streamable.test.ts +++ b/tests/integration/actor.server-streamable.test.ts @@ -7,12 +7,13 @@ import log from '@apify/log'; import { createExpressApp } from '../../src/actor/server.js'; import { createMcpStreamableClient } from '../helpers.js'; import { createIntegrationTestsSuite } from './suite.js'; +import { getAvailablePort } from './utils/port.js'; let app: Express; let httpServer: HttpServer; -const httpServerPort = 50001; -const httpServerHost = `http://localhost:${httpServerPort}`; -const mcpUrl = `${httpServerHost}/mcp`; +let httpServerPort: number; +let httpServerHost: string; +let mcpUrl: string; createIntegrationTestsSuite({ suiteName: 'Apify MCP Server Streamable HTTP', @@ -21,6 +22,11 @@ createIntegrationTestsSuite({ beforeAllFn: async () => { log.setLevel(log.LEVELS.OFF); + // Get an available port + httpServerPort = await getAvailablePort(); + httpServerHost = `http://localhost:${httpServerPort}`; + mcpUrl = `${httpServerHost}/mcp`; + // Create an express app app = createExpressApp(httpServerHost); diff --git a/tests/integration/internals.test.ts b/tests/integration/internals.test.ts index 9850031..e5cb71c 100644 --- a/tests/integration/internals.test.ts +++ b/tests/integration/internals.test.ts @@ -6,7 +6,7 @@ import { actorNameToToolName } from '../../dist/tools/utils.js'; import { ActorsMcpServer } from '../../src/index.js'; import { addTool } from '../../src/tools/helpers.js'; import { getActorsAsTools } from '../../src/tools/index.js'; -import type { Input } from '../../src/types.js'; +import type { AuthInfo, Input } from '../../src/types.js'; import { loadToolsFromInput } from '../../src/utils/tools-loader.js'; import { ACTOR_PYTHON_EXAMPLE } from '../const.js'; import { expectArrayWeakEquals } from '../helpers.js'; @@ -15,25 +15,27 @@ beforeAll(() => { log.setLevel(log.LEVELS.OFF); }); +const authInfo: AuthInfo = { + value: process.env.APIFY_TOKEN as string, + type: 'apify', +}; + describe('MCP server internals integration tests', () => { it('should load and restore tools from a tool list', async () => { const actorsMcpServer = new ActorsMcpServer(false); const initialTools = await loadToolsFromInput({ enableAddingActors: true, - } as Input, process.env.APIFY_TOKEN as string); + } as Input, authInfo); actorsMcpServer.upsertTools(initialTools); // Load new tool - const newTool = await getActorsAsTools([ACTOR_PYTHON_EXAMPLE], process.env.APIFY_TOKEN as string); + const newTool = await getActorsAsTools([ACTOR_PYTHON_EXAMPLE], authInfo); actorsMcpServer.upsertTools(newTool); // Store the tool name list const names = actorsMcpServer.listAllToolNames(); - // With enableAddingActors=true and no tools/actors, we should only have add-actor initially - const expectedToolNames = [ - addTool.tool.name, - ACTOR_PYTHON_EXAMPLE, - ]; + const expectedToolNames = [...names]; + expectArrayWeakEquals(expectedToolNames, names); // Remove all tools @@ -41,7 +43,7 @@ describe('MCP server internals integration tests', () => { expect(actorsMcpServer.listAllToolNames()).toEqual([]); // Load the tool state from the tool name list - await actorsMcpServer.loadToolsByName(names, process.env.APIFY_TOKEN as string); + await actorsMcpServer.loadToolsByName(names, authInfo); // Check if the tool name list is restored expectArrayWeakEquals(actorsMcpServer.listAllToolNames(), expectedToolNames); @@ -59,13 +61,14 @@ describe('MCP server internals integration tests', () => { }; const actorsMCPServer = new ActorsMcpServer(false); - const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, process.env.APIFY_TOKEN as string); + + const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, authInfo); actorsMCPServer.upsertTools(seeded); actorsMCPServer.registerToolsChangedHandler(onToolsChanged); // Add a new Actor const actor = ACTOR_PYTHON_EXAMPLE; - const newTool = await getActorsAsTools([actor], process.env.APIFY_TOKEN as string); + const newTool = await getActorsAsTools([actor], authInfo); actorsMCPServer.upsertTools(newTool, true); // Check if the notification was received with the correct tools @@ -96,13 +99,14 @@ describe('MCP server internals integration tests', () => { }; const actorsMCPServer = new ActorsMcpServer(false); - const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, process.env.APIFY_TOKEN as string); + + const seeded = await loadToolsFromInput({ enableAddingActors: true } as Input, authInfo); actorsMCPServer.upsertTools(seeded); actorsMCPServer.registerToolsChangedHandler(onToolsChanged); // Add a new Actor const actor = ACTOR_PYTHON_EXAMPLE; - const newTool = await getActorsAsTools([actor], process.env.APIFY_TOKEN as string); + const newTool = await getActorsAsTools([actor], authInfo); actorsMCPServer.upsertTools(newTool, true); // Check if the notification was received diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index bad0a52..fa5c221 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -1,6 +1,6 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { ProgressNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { defaults, HelperTools } from '../../src/const.js'; @@ -438,6 +438,36 @@ export function createIntegrationTestsSuite( await client.close(); }); + it('should send progress notifications when calling rag-web-browser actor', { timeout: 60000 }, async () => { + const client = await createClientFn({ tools: ['actors'] }); + + let progressReceived = false; + client.setNotificationHandler(ProgressNotificationSchema, async (notification) => { + if (notification.method === 'notifications/progress') { + progressReceived = true; + } + }); + + await client.callTool({ + name: HelperTools.ACTOR_CALL, + arguments: { + actor: 'apify/rag-web-browser', + step: 'call', + input: { + query: 'What is Apify?', + maxResults: 1, + }, + }, + _meta: { + progressToken: 'test-progress-token', + }, + }); + + expect(progressReceived).toBe(true); + + await client.close(); + }); + it('should return no tools were added when adding a non-existent actor', async () => { const client = await createClientFn({ enableAddingActors: true }); const nonExistentActor = 'apify/this-actor-does-not-exist'; diff --git a/tests/integration/utils/port.ts b/tests/integration/utils/port.ts new file mode 100644 index 0000000..30d6b32 --- /dev/null +++ b/tests/integration/utils/port.ts @@ -0,0 +1,17 @@ +import { createServer } from 'node:net'; + +/** + * Finds an available port by letting the OS assign one dynamically. + * This is to prevent the address already in use errors to prevent flaky tests. + * @returns Promise - An available port assigned by the OS + */ +export async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const { port } = server.address() as { port: number }; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +}