Skip to content

Commit f3a23a2

Browse files
authored
feat: Telemery add soft fail for client errors (#350)
* feat: update tools status as soft_fail
1 parent 08629b8 commit f3a23a2

18 files changed

Lines changed: 320 additions & 152 deletions

src/const.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const ACTOR_MAX_MEMORY_MBYTES = 4_096; // If the Actor requires 8GB of me
1010
// Tool output
1111
/**
1212
* Usual tool output limit is 25k tokens where 1 token =~ 4 characters
13-
* thus 50k chars so we have some buffer becase there was some issue with Claude code Actor call output token count.
13+
* thus 50k chars so we have some buffer because there was some issue with Claude code Actor call output token count.
1414
* This is primarily used for Actor tool call output, but we can then
1515
* reuse this in other tools as well.
1616
*/
@@ -120,10 +120,9 @@ export const TELEMETRY_ENV = {
120120
DEV: 'DEV',
121121
PROD: 'PROD',
122122
} as const;
123-
export type TelemetryEnv = (typeof TELEMETRY_ENV)[keyof typeof TELEMETRY_ENV];
124123

125124
export const DEFAULT_TELEMETRY_ENABLED = true;
126-
export const DEFAULT_TELEMETRY_ENV: TelemetryEnv = TELEMETRY_ENV.PROD;
125+
export const DEFAULT_TELEMETRY_ENV = TELEMETRY_ENV.PROD;
127126

128127
// We are using the same values as apify-core for consistency (despite that we ship events of different types).
129128
// https://github.com/apify/apify-core/blob/2284766c122c6ac5bc4f27ec28051f4057d6f9c0/src/packages/analytics/src/server/segment.ts#L28
@@ -133,6 +132,18 @@ export const SEGMENT_FLUSH_AT_EVENTS = 50;
133132
// Flush interval in milliseconds (default is 10000)
134133
export const SEGMENT_FLUSH_INTERVAL_MS = 5_000;
135134

135+
// Tool status
136+
/**
137+
* Unified status constants for tool execution lifecycle.
138+
* Single source of truth for all tool status values.
139+
*/
140+
export const TOOL_STATUS = {
141+
SUCCEEDED: 'SUCCEEDED',
142+
FAILED: 'FAILED',
143+
ABORTED: 'ABORTED',
144+
SOFT_FAIL: 'SOFT_FAIL',
145+
} as const;
146+
136147
export const SERVER_INSTRUCTIONS = `
137148
Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation.
138149
These tools are called **Actors**. They enable you to extract structured data from social media, e-commerce, search engines, maps, travel sites, and many other sources.

src/mcp/server.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION,
3737
SKYFIRE_README_CONTENT,
3838
SKYFIRE_TOOL_INSTRUCTIONS,
39-
type TelemetryEnv,
39+
TOOL_STATUS,
4040
} from '../const.js';
4141
import { prompts } from '../prompts/index.js';
4242
import { getTelemetryEnv, trackToolCall } from '../telemetry.js';
@@ -47,14 +47,17 @@ import type {
4747
ActorsMcpServerOptions,
4848
ActorTool,
4949
HelperTool,
50+
TelemetryEnv,
5051
ToolCallTelemetryProperties,
5152
ToolEntry,
53+
ToolStatus,
5254
} from '../types.js';
5355
import { buildActorResponseContent } from '../utils/actor-response.js';
5456
import { parseBooleanFromString } from '../utils/generic.js';
5557
import { logHttpError } from '../utils/logging.js';
5658
import { buildMCPResponse } from '../utils/mcp.js';
5759
import { createProgressTracker } from '../utils/progress.js';
60+
import { getToolStatusFromError } from '../utils/tool-status.js';
5861
import { cloneToolEntry, getToolPublicFieldOnly } from '../utils/tools.js';
5962
import { getUserIdFromTokenCached } from '../utils/userid-cache.js';
6063
import { getPackageVersion } from '../utils/version.js';
@@ -581,7 +584,7 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool
581584
const { telemetryData, userId } = await this.prepareTelemetryData(tool, mcpSessionId, apifyToken);
582585

583586
const startTime = Date.now();
584-
let toolStatus: 'succeeded' | 'failed' | 'aborted' = 'succeeded';
587+
let toolStatus: ToolStatus = TOOL_STATUS.SUCCEEDED;
585588

586589
try {
587590
// Handle internal tool
@@ -605,8 +608,19 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool
605608
if (progressTracker) {
606609
progressTracker.stop();
607610
}
608-
toolStatus = ('isError' in res && res.isError) ? 'failed' : 'succeeded';
609-
return { ...res };
611+
612+
// If tool provided internal status, use it; otherwise infer from isError flag
613+
const { internalToolStatus, ...rest } = res as { internalToolStatus?: ToolStatus; isError?: boolean };
614+
if (internalToolStatus !== undefined) {
615+
toolStatus = internalToolStatus;
616+
} else if ('isError' in rest && rest.isError) {
617+
toolStatus = TOOL_STATUS.FAILED;
618+
} else {
619+
toolStatus = TOOL_STATUS.SUCCEEDED;
620+
}
621+
622+
// Never expose internal _toolStatus field to MCP clients
623+
return { ...rest };
610624
}
611625

612626
if (tool.type === 'actor-mcp') {
@@ -618,8 +632,8 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool
618632
Please verify the server URL is correct and accessible, and ensure you have a valid Apify token with appropriate permissions.`;
619633
log.softFail(msg, { statusCode: 408 }); // 408 Request Timeout
620634
await this.server.sendLoggingMessage({ level: 'error', data: msg });
621-
toolStatus = 'failed';
622-
return buildMCPResponse([msg], true);
635+
toolStatus = TOOL_STATUS.SOFT_FAIL;
636+
return buildMCPResponse({ texts: [msg], isError: true });
623637
}
624638

625639
// Only set up notification handlers if progressToken is provided by the client
@@ -649,6 +663,8 @@ Please verify the server URL is correct and accessible, and ensure you have a va
649663
timeout: EXTERNAL_TOOL_CALL_TIMEOUT_MSEC,
650664
});
651665

666+
// For external MCP servers we do not try to infer soft_fail vs failed from isError.
667+
// We treat the call as succeeded at the telemetry layer unless an actual error is thrown.
652668
return { ...res };
653669
} finally {
654670
if (client) await client.close();
@@ -658,7 +674,7 @@ Please verify the server URL is correct and accessible, and ensure you have a va
658674
// Handle actor tool
659675
if (tool.type === 'actor') {
660676
if (this.options.skyfireMode && args['skyfire-pay-id'] === undefined) {
661-
return buildMCPResponse([SKYFIRE_TOOL_INSTRUCTIONS]);
677+
return buildMCPResponse({ texts: [SKYFIRE_TOOL_INSTRUCTIONS] });
662678
}
663679

664680
// Create progress tracker if progressToken is available
@@ -686,7 +702,7 @@ Please verify the server URL is correct and accessible, and ensure you have a va
686702
);
687703

688704
if (!callResult) {
689-
toolStatus = 'aborted';
705+
toolStatus = TOOL_STATUS.ABORTED;
690706
// Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request
691707
// https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements
692708
return { };
@@ -700,14 +716,17 @@ Please verify the server URL is correct and accessible, and ensure you have a va
700716
}
701717
}
702718
}
719+
// If we reached here without returning, it means the tool type was not recognized (user error)
720+
toolStatus = TOOL_STATUS.SOFT_FAIL;
703721
} catch (error) {
704-
toolStatus = extra.signal?.aborted ? 'aborted' : 'failed';
722+
toolStatus = getToolStatusFromError(error, Boolean(extra.signal?.aborted));
705723
logHttpError(error, 'Error occurred while calling tool', { toolName: name });
706724
const errorMessage = (error instanceof Error) ? error.message : 'Unknown error';
707-
return buildMCPResponse([
708-
`Error calling tool "${name}": ${errorMessage}.
709-
Please verify the tool name, input parameters, and ensure all required resources are available.`,
710-
], true);
725+
return buildMCPResponse({
726+
texts: [`Error calling tool "${name}": ${errorMessage}. Please verify the tool name, input parameters, and ensure all required resources are available.`],
727+
isError: true,
728+
toolStatus,
729+
});
711730
} finally {
712731
this.finalizeAndTrackTelemetry(telemetryData, userId, startTime, toolStatus);
713732
}
@@ -735,13 +754,13 @@ Please verify the tool name and ensure the tool is properly registered.`;
735754
* @param telemetryData - Telemetry data to finalize and track (null if telemetry is disabled)
736755
* @param userId - Apify user ID (string or null if not available)
737756
* @param startTime - Timestamp when the tool call started
738-
* @param toolStatus - Final status of the tool call ('succeeded', 'failed', or 'aborted')
757+
* @param toolStatus - Final status of the tool call
739758
*/
740759
private finalizeAndTrackTelemetry(
741760
telemetryData: ToolCallTelemetryProperties | null,
742761
userId: string | null,
743762
startTime: number,
744-
toolStatus: 'succeeded' | 'failed' | 'aborted',
763+
toolStatus: ToolStatus,
745764
): void {
746765
if (!telemetryData) {
747766
return;
@@ -787,7 +806,7 @@ Please verify the tool name and ensure the tool is properly registered.`;
787806
mcp_session_id: mcpSessionId || '',
788807
transport_type: this.options.transportType || '',
789808
tool_name: toolFullName,
790-
tool_status: 'succeeded', // Will be updated in finally
809+
tool_status: TOOL_STATUS.SUCCEEDED, // Will be updated in finally
791810
tool_exec_time_ms: 0, // Will be calculated in finally
792811
};
793812

src/stdio.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ import { hideBin } from 'yargs/helpers';
2929
import log from '@apify/log';
3030

3131
import { ApifyClient } from './apify-client.js';
32-
import { DEFAULT_TELEMETRY_ENV, TELEMETRY_ENV, type TelemetryEnv } from './const.js';
32+
import { DEFAULT_TELEMETRY_ENV, TELEMETRY_ENV } from './const.js';
3333
import { processInput } from './input.js';
3434
import { ActorsMcpServer } from './mcp/server.js';
3535
import { getTelemetryEnv } from './telemetry.js';
36-
import type { Input, ToolSelector } from './types.js';
36+
import type { Input, TelemetryEnv, ToolSelector } from './types.js';
3737
import { parseCommaSeparatedList } from './utils/generic.js';
3838
import { loadToolsFromInput } from './utils/tools-loader.js';
3939

src/telemetry.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import {
99
SEGMENT_FLUSH_AT_EVENTS,
1010
SEGMENT_FLUSH_INTERVAL_MS,
1111
TELEMETRY_ENV,
12-
type TelemetryEnv,
1312
} from './const.js';
14-
import type { ToolCallTelemetryProperties } from './types.js';
13+
import type { TelemetryEnv, ToolCallTelemetryProperties } from './types.js';
1514

1615
const DEV_WRITE_KEY = '9rPHlMtxX8FJhilGEwkfUoZ0uzWxnzcT';
1716
const PROD_WRITE_KEY = 'cOkp5EIJaN69gYaN8bcp7KtaD0fGABwJ';

src/tools/actor.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@ import zodToJsonSchema from 'zod-to-json-schema';
66
import log from '@apify/log';
77

88
import { ApifyClient } from '../apify-client.js';
9-
import {
10-
ACTOR_MAX_MEMORY_MBYTES,
9+
import { ACTOR_MAX_MEMORY_MBYTES,
1110
CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG,
1211
HelperTools,
1312
RAG_WEB_BROWSER,
1413
RAG_WEB_BROWSER_ADDITIONAL_DESC,
1514
SKYFIRE_TOOL_INSTRUCTIONS,
1615
TOOL_MAX_OUTPUT_CHARS,
17-
} from '../const.js';
16+
TOOL_STATUS } from '../const.js';
1817
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
1918
import { connectMCPClient } from '../mcp/client.js';
2019
import { getMCPServerTools } from '../mcp/proxy.js';
@@ -408,9 +407,11 @@ EXAMPLES:
408407

409408
// Standby Actors, thus MCPs, are not supported in Skyfire mode
410409
if (isActorMcpServer && apifyMcpServer.options.skyfireMode) {
411-
return buildMCPResponse([
412-
`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.`,
413-
], true);
410+
return buildMCPResponse({
411+
texts: [`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.`],
412+
isError: true,
413+
toolStatus: TOOL_STATUS.SOFT_FAIL,
414+
});
414415
}
415416

416417
try {
@@ -423,24 +424,34 @@ EXAMPLES:
423424
try {
424425
client = await connectMCPClient(mcpServerUrl, apifyToken);
425426
if (!client) {
426-
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true);
427+
return buildMCPResponse({
428+
texts: [`Failed to connect to MCP server ${mcpServerUrl}`],
429+
isError: true,
430+
toolStatus: TOOL_STATUS.SOFT_FAIL,
431+
});
427432
}
428433
const toolsResponse = await client.listTools();
429434

430435
const toolsInfo = toolsResponse.tools.map((tool) => `**${tool.name}**\n${tool.description || 'No description'}\nInput schema:\n\`\`\`json\n${JSON.stringify(tool.inputSchema)}\n\`\`\``,
431436
).join('\n\n');
432437

433-
return buildMCPResponse([`This is an MCP Server Actor with the following tools:\n\n${toolsInfo}\n\nTo call a tool, use step="call" with actor name format: "${baseActorName}:{toolName}"`]);
438+
return buildMCPResponse({
439+
texts: [`This is an MCP Server Actor with the following tools:\n\n${toolsInfo}\n\nTo call a tool, use step="call" with actor name format: "${baseActorName}:{toolName}"`],
440+
});
434441
} finally {
435442
if (client) await client.close();
436443
}
437444
} else {
438-
// Regular actor: return schema
445+
// Regular Actor: return schema
439446
const details = await fetchActorDetails(apifyClientForDefinition, baseActorName);
440447
if (!details) {
441-
return buildMCPResponse([`Actor information for '${baseActorName}' was not found.
448+
return buildMCPResponse({
449+
texts: [`Actor information for '${baseActorName}' was not found.
442450
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
443-
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true);
451+
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`],
452+
isError: true,
453+
toolStatus: TOOL_STATUS.SOFT_FAIL,
454+
});
444455
}
445456
const content = [
446457
`Actor name: ${actorName}`,
@@ -452,7 +463,7 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
452463
if (apifyMcpServer.options.skyfireMode) {
453464
content.push(SKYFIRE_TOOL_INSTRUCTIONS);
454465
}
455-
return buildMCPResponse(content);
466+
return buildMCPResponse({ texts: content });
456467
}
457468
}
458469

@@ -478,28 +489,41 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
478489

479490
// Step 2: Call the Actor
480491
if (!input) {
481-
return buildMCPResponse([`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`], true);
492+
// Missing input is most likely an LLM error, so NOT marking as a soft-fail to track potential issues with tool description
493+
return buildMCPResponse({
494+
texts: [`Input is required when step="call". Please provide the input parameter based on the Actor's input schema.`],
495+
isError: true,
496+
});
482497
}
483498

484499
// Handle the case where LLM does not respect instructions when calling MCP server Actors
485500
// and does not provide the tool name.
486501
const isMcpToolNameInvalid = mcpToolName === undefined || mcpToolName.trim().length === 0;
487502
if (isActorMcpServer && isMcpToolNameInvalid) {
488-
return buildMCPResponse([CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG], true);
503+
return buildMCPResponse({
504+
texts: [CALL_ACTOR_MCP_MISSING_TOOL_NAME_MSG],
505+
isError: true,
506+
});
489507
}
490508

491509
// Handle MCP tool calls
492510
if (mcpToolName) {
493511
if (!isActorMcpServer) {
494-
return buildMCPResponse([`Actor '${baseActorName}' is not an MCP server.`], true);
512+
return buildMCPResponse({
513+
texts: [`Actor '${baseActorName}' is not an MCP server.`],
514+
isError: true,
515+
});
495516
}
496517

497518
const mcpServerUrl = mcpServerUrlOrFalse;
498519
let client: Client | null = null;
499520
try {
500521
client = await connectMCPClient(mcpServerUrl, apifyToken);
501522
if (!client) {
502-
return buildMCPResponse([`Failed to connect to MCP server ${mcpServerUrl}`], true);
523+
return buildMCPResponse({
524+
texts: [`Failed to connect to MCP server ${mcpServerUrl}`],
525+
isError: true,
526+
});
503527
}
504528

505529
const result = await client.callTool({
@@ -517,9 +541,13 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
517541
const [actor] = await getActorsAsTools([actorName], apifyClient);
518542

519543
if (!actor) {
520-
return buildMCPResponse([`Actor '${actorName}' was not found.
544+
return buildMCPResponse({
545+
texts: [`Actor '${actorName}' was not found.
521546
Please verify Actor ID or name format (e.g., "username/name" like "apify/rag-web-browser") and ensure that the Actor exists.
522-
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`], true);
547+
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.`],
548+
isError: true,
549+
toolStatus: TOOL_STATUS.SOFT_FAIL,
550+
});
523551
}
524552

525553
if (!actor.ajvValidate(input)) {
@@ -531,7 +559,7 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
531559
if (errors && errors.length > 0) {
532560
content.push(`Validation errors: ${errors.map((e) => (e as { message?: string }).message).join(', ')}`);
533561
}
534-
return buildMCPResponse(content);
562+
return buildMCPResponse({ texts: content, isError: true });
535563
}
536564

537565
const callResult = await callActorGetDataset(
@@ -554,9 +582,13 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
554582
return { content };
555583
} catch (error) {
556584
logHttpError(error, 'Failed to call Actor', { actorName, performStep });
557-
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}.
585+
// Let the server classify the error; we only mark it as an MCP error response
586+
return buildMCPResponse({
587+
texts: [`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}.
558588
Please verify the Actor name, input parameters, and ensure the Actor exists.
559-
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`], true);
589+
You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}, or get Actor details using: ${HelperTools.ACTOR_GET_DETAILS}.`],
590+
isError: true,
591+
});
560592
}
561593
},
562594
};

0 commit comments

Comments
 (0)