Skip to content
Merged
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
2 changes: 1 addition & 1 deletion evals/evaluation-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function filterById(testCases: TestCase[], idPattern: string): TestCase[]
export async function loadTools(): Promise<ToolBase[]> {
const apifyClient = new ApifyClient({ token: process.env.APIFY_API_TOKEN || '' });
const urlTools = await processParamsGetTools('', apifyClient);
return urlTools.map((t: ToolEntry) => getToolPublicFieldOnly(t.tool)) as ToolBase[];
return urlTools.map((t: ToolEntry) => getToolPublicFieldOnly(t)) as ToolBase[];
}

export function transformToolsToOpenAIFormat(tools: ToolBase[]): OpenAI.Chat.Completions.ChatCompletionTool[] {
Expand Down
50 changes: 27 additions & 23 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ 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
* @param apifyClient
*/
public async loadToolsByName(toolNames: string[], apifyClient: ApifyClient) {
const loadedTools = this.listAllToolNames();
Expand Down Expand Up @@ -215,7 +215,7 @@ 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 apifyClient
* @returns Promise<ToolEntry[]> - Array of loaded tool entries
*/
public async loadActorsAsTools(actorIdsOrNames: string[], apifyClient: ApifyClient): Promise<ToolEntry[]> {
Expand Down Expand Up @@ -483,7 +483,9 @@ export class ActorsMcpServer {

// Validate token
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.';
const msg = `APIFY_TOKEN is required but was not provided.
Please set the APIFY_TOKEN environment variable or pass it as a parameter in the request body.
You can obtain your Apify token from https://console.apify.com/account/integrations.`;
log.softFail(msg, { statusCode: 400 });
await this.server.sendLoggingMessage({ level: 'error', data: msg });
throw new McpError(
Expand All @@ -507,7 +509,10 @@ export class ActorsMcpServer {
const tool = Array.from(this.tools.values())
.find((t) => t.name === name || (t.type === 'actor' && t.actorFullName === name));
if (!tool) {
const msg = `Tool ${name} not found. Available tools: ${this.listToolNames().join(', ')}`;
const availableTools = this.listToolNames();
const msg = `Tool "${name}" was not found.
Available tools: ${availableTools.length > 0 ? availableTools.join(', ') : 'none'}.
Please verify the tool name is correct. You can list all available tools using the tools/list request.`;
log.softFail(msg, { statusCode: 404 });
await this.server.sendLoggingMessage({ level: 'error', data: msg });
throw new McpError(
Expand All @@ -516,7 +521,8 @@ export class ActorsMcpServer {
);
}
if (!args) {
const msg = `Missing arguments for tool ${name}`;
const msg = `Missing arguments for tool "${name}".
Please provide the required arguments for this tool. Check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool to see what parameters are required.`;
log.softFail(msg, { statusCode: 400 });
await this.server.sendLoggingMessage({ level: 'error', data: msg });
throw new McpError(
Expand All @@ -529,7 +535,11 @@ export class ActorsMcpServer {
args = decodeDotPropertyNames(args);
log.debug('Validate arguments for tool', { toolName: tool.name, input: args });
if (!tool.ajvValidate(args)) {
const msg = `Invalid arguments for tool ${tool.name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(tool?.ajvValidate.errors)}`;
const errors = tool?.ajvValidate.errors || [];
const errorMessages = errors.map((e: { message?: string; instancePath?: string }) => `${e.instancePath || 'root'}: ${e.message || 'validation error'}`).join('; ');
const msg = `Invalid arguments for tool "${tool.name}".
Validation errors: ${errorMessages}.
Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool and ensure all required parameters are provided with correct types and values.`;
log.softFail(msg, { statusCode: 400 });
await this.server.sendLoggingMessage({ level: 'error', data: msg });
throw new McpError(
Expand Down Expand Up @@ -569,16 +579,11 @@ export class ActorsMcpServer {
try {
client = await connectMCPClient(tool.serverUrl, apifyToken);
if (!client) {
const msg = `Failed to connect to MCP server ${tool.serverUrl}`;
// Note: Timeout errors are already logged as warning in connectMCPClient
// This is a fallback log for when connection fails (client-side issue)
const msg = `Failed to connect to MCP server at "${tool.serverUrl}".
Please verify the server URL is correct and accessible, and ensure you have a valid Apify token with appropriate permissions.`;
log.softFail(msg, { statusCode: 408 }); // 408 Request Timeout
await this.server.sendLoggingMessage({ level: 'error', data: msg });
return {
content: [
{ type: 'text', text: msg },
],
};
return buildMCPResponse([msg], true);
}

// Only set up notification handlers if progressToken is provided by the client
Expand Down Expand Up @@ -619,12 +624,7 @@ export class ActorsMcpServer {
if (this.options.skyfireMode
&& args['skyfire-pay-id'] === undefined
) {
return {
content: [{
type: 'text',
text: SKYFIRE_TOOL_INSTRUCTIONS,
}],
};
return buildMCPResponse([SKYFIRE_TOOL_INSTRUCTIONS]);
}

// Create progress tracker if progressToken is available
Expand Down Expand Up @@ -669,11 +669,15 @@ export class ActorsMcpServer {
logHttpError(error, 'Error occurred while calling tool', { toolName: name });
const errorMessage = (error instanceof Error) ? error.message : 'Unknown error';
return buildMCPResponse([
`Error calling tool ${name}: ${errorMessage}`,
]);
`Error calling tool "${name}": ${errorMessage}.
Please verify the tool name, input parameters, and ensure all required resources are available.`,
], true);
}

const msg = `Unknown tool: ${name}`;
const availableTools = this.listToolNames();
const msg = `Unknown tool type for "${name}".
Available tools: ${availableTools.length > 0 ? availableTools.join(', ') : 'none'}.
Please verify the tool name and ensure the tool is properly registered.`;
log.softFail(msg, { statusCode: 404 });
await this.server.sendLoggingMessage({
level: 'error',
Expand Down
5 changes: 3 additions & 2 deletions src/mcp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ 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 url The URL to process
* @param apifyClient The Apify client instance
* @param initializeRequestData Optional initialize request data
*/
export async function processParamsGetTools(url: string, apifyClient: ApifyClient, initializeRequestData?: InitializeRequest) {
const input = parseInputParamsFromUrl(url);
Expand Down
28 changes: 18 additions & 10 deletions src/tools/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ export async function getActorsAsTools(
try {
const actorDefinitionPruned = await getActorDefinition(actorIdOrName, apifyClient);
if (!actorDefinitionPruned) {
log.info('Actor not found or definition is not available', { actorName: actorIdOrName });
log.softFail('Actor not found or definition is not available', { actorName: actorIdOrName, statusCode: 404 });
return null;
}
// Cache the pruned Actor definition
Expand Down Expand Up @@ -408,7 +408,9 @@ EXAMPLES:

// Standby Actors, thus MCPs, are not supported in Skyfire mode
if (isActorMcpServer && apifyMcpServer.options.skyfireMode) {
return buildMCPResponse([`MCP server Actors are not supported in Skyfire mode. Please use a regular Apify token without Skyfire.`]);
return buildMCPResponse([
`This Actor (${actorName}) is an MCP server and cannot be accessed using a Skyfire token. To use this Actor, please provide a valid Apify token instead of a Skyfire token.`,
], true);
}

try {
Expand All @@ -421,7 +423,7 @@ EXAMPLES:
try {
client = await connectMCPClient(mcpServerUrl, apifyToken);
if (!client) {
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]);
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true);
}
const toolsResponse = await client.listTools();

Expand All @@ -436,7 +438,9 @@ EXAMPLES:
// Regular actor: return schema
const details = await fetchActorDetails(apifyClientForDefinition, baseActorName);
if (!details) {
return buildMCPResponse([`Actor information for '${baseActorName}' was not found. Please check the Actor ID or name and ensure the Actor exists.`]);
return buildMCPResponse([`Actor information for '${baseActorName}' was not found.
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true);
}
const content = [
`Actor name: ${actorName}`,
Expand Down Expand Up @@ -474,28 +478,28 @@ EXAMPLES:

// Step 2: Call the Actor
if (!input) {
return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`]);
return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`], true);
}

// Handle the case where LLM does not respect instructions when calling MCP server Actors
// and does not provide the tool name.
const isMcpToolNameInvalid = mcpToolName === undefined || mcpToolName.trim().length === 0;
if (isActorMcpServer && isMcpToolNameInvalid) {
return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG]);
return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG], true);
}

// Handle MCP tool calls
if (mcpToolName) {
if (!isActorMcpServer) {
return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`]);
return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`], true);
}

const mcpServerUrl = mcpServerUrlOrFalse;
let client: Client | null = null;
try {
client = await connectMCPClient(mcpServerUrl, apifyToken);
if (!client) {
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`]);
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true);
}

const result = await client.callTool({
Expand All @@ -513,7 +517,9 @@ EXAMPLES:
const [actor] = await getActorsAsTools([actorName], apifyClient);

if (!actor) {
return buildMCPResponse([`Actor '${actorName}' was not found.`]);
return buildMCPResponse([`Actor '${actorName}' was not found.
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true);
}

if (!actor.ajvValidate(input)) {
Expand Down Expand Up @@ -548,7 +554,9 @@ EXAMPLES:
return { content };
} catch (error) {
logHttpError(error, 'Failed to call Actor', { actorName, performStep });
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]);
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}.
Please verify the Actor name, input parameters, and ensure the Actor exists.
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`], true);
}
},
};
5 changes: 3 additions & 2 deletions src/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import { filterSchemaProperties, shortenProperties } from './utils.js';
* 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 {ApifyClient} apifyClient - The Apify client instance.
* @param {number} limit - Truncate the README to this limit.
* @param {string} apifyToken
* @returns {Promise<ActorDefinitionWithDesc | null>} - The actor definition with description or null if not found.
*/
export async function getActorDefinition(
Expand Down Expand Up @@ -138,7 +138,7 @@ export const actorDefinitionTool: ToolEntry = {
try {
const v = await getActorDefinition(parsed.actorName, apifyClient, parsed.limit);
if (!v) {
return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }] };
return { content: [{ type: 'text', text: `Actor '${parsed.actorName}' not found.` }], isError: true };
}
if (v && v.input && 'properties' in v.input && v.input) {
const properties = filterSchemaProperties(v.input.properties as { [key: string]: ISchemaProperties });
Expand All @@ -151,6 +151,7 @@ export const actorDefinitionTool: ToolEntry = {
type: 'text',
text: `Failed to fetch Actor definition: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
Expand Down
8 changes: 4 additions & 4 deletions src/tools/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ USAGE EXAMPLES:
const client = new ApifyClient({ token: apifyToken });
const v = await client.dataset(parsed.datasetId).get();
if (!v) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true };
}
return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] };
},
Expand Down Expand Up @@ -119,7 +119,7 @@ USAGE EXAMPLES:
flatten,
});
if (!v) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true };
}
return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(v)}\n\`\`\`` }] };
},
Expand Down Expand Up @@ -175,7 +175,7 @@ USAGE EXAMPLES:
});

if (!datasetResponse) {
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }] };
return { content: [{ type: 'text', text: `Dataset '${parsed.datasetId}' not found.` }], isError: true };
}

const datasetItems = datasetResponse.items;
Expand All @@ -192,7 +192,7 @@ USAGE EXAMPLES:
});

if (!schema) {
return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }] };
return { content: [{ type: 'text', text: `Failed to generate schema for dataset '${parsed.datasetId}'.` }], isError: true };
}

return {
Expand Down
19 changes: 11 additions & 8 deletions src/tools/fetch-actor-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HelperTools } from '../const.js';
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
import { fetchActorDetails } from '../utils/actor-details.js';
import { ajv } from '../utils/ajv.js';
import { buildMCPResponse } from '../utils/mcp.js';

const fetchActorDetailsToolArgsSchema = z.object({
actor: z.string()
Expand Down Expand Up @@ -40,26 +41,28 @@ USAGE EXAMPLES:
const apifyClient = new ApifyClient({ token: apifyToken });
const details = await fetchActorDetails(apifyClient, 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.` }],
};
const texts = [`Actor information for '${parsed.actor}' was not found.
Please verify Actor ID or name format and ensure that the Actor exists.
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`,
];
return buildMCPResponse(texts, true);
}

const actorUrl = `https://apify.com/${details.actorInfo.username}/${details.actorInfo.name}`;
// Add link to README title
details.readme = details.readme.replace(/^# /, `# [README](${actorUrl}/readme): `);

const content = [
{ type: 'text', text: `# Actor information\n${details.actorCard}` },
{ type: 'text', text: `${details.readme}` },
const texts = [
`# Actor information\n${details.actorCard}`,
`${details.readme}`,
];

// Include input schema if it has properties
if (details.inputSchema.properties || Object.keys(details.inputSchema.properties).length !== 0) {
content.push({ type: 'text', text: `# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\`` });
texts.push(`# [Input schema](${actorUrl}/input)\n\`\`\`json\n${JSON.stringify(details.inputSchema)}\n\`\`\``);
}
// Return the actor card, README, and input schema (if it has non-empty properties) as separate text blocks
// This allows better formatting in the final output
return { content };
return buildMCPResponse(texts);
},
} as const;
Loading