diff --git a/README.md b/README.md index ba7c05e6..2b1a97b2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The Apify Model Context Protocol (MCP) server at [**mcp.apify.com**](https://mcp - [🤖 MCP clients and examples](#-mcp-clients-and-examples) - [🪄 Try Apify MCP instantly](#-try-apify-mcp-instantly) - [🛠️ Tools, resources, and prompts](#-tools-resources-and-prompts) +- [📊 Telemetry](#telemetry) - [🐛 Troubleshooting (local MCP server)](#-troubleshooting-local-mcp-server) - [⚙️ Development](#-development) - [🤝 Contributing](#-contributing) @@ -266,13 +267,32 @@ The server provides a set of predefined example prompts to help you get started The server does not yet provide any resources. -### Debugging the NPM package +## 📡 Telemetry -To debug the server, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) tool: +The Apify MCP Server collects telemetry data about tool calls to help Apify understand usage patterns and improve the service. +By default, telemetry is **enabled** for all tool calls. -```shell -export APIFY_TOKEN="your-apify-token" -npx @modelcontextprotocol/inspector npx -y @apify/actors-mcp-server +### Opting out of telemetry + +You can opt out of telemetry by setting the `--telemetry-enabled` CLI flag to `false` or the `TELEMETRY_ENABLED` environment variable to `false`. +CLI flags take precedence over environment variables. + +#### Examples + +**For the remote server (mcp.apify.com)**: +```text +# Disable via URL parameter +https://mcp.apify.com?telemetry-enabled=false +``` + +**For the local stdio server**: +```bash +# Disable via CLI flag +npx @apify/actors-mcp-server --telemetry-enabled=false + +# Or set environment variable +export TELEMETRY_ENABLED=false +npx @apify/actors-mcp-server ``` # ⚙️ Development @@ -333,6 +353,15 @@ The Apify MCP Server is also available on [Docker Hub](https://hub.docker.com/mc - Make sure the `APIFY_TOKEN` environment variable is set. - Always use the latest version of the MCP server by using `@apify/actors-mcp-server@latest`. +### Debugging the NPM package + +To debug the server, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) tool: + +```shell +export APIFY_TOKEN="your-apify-token" +npx @modelcontextprotocol/inspector npx -y @apify/actors-mcp-server +``` + ## 💡 Limitations The Actor input schema is processed to be compatible with most MCP clients while adhering to [JSON Schema](https://json-schema.org/) standards. The processing includes: diff --git a/package-lock.json b/package-lock.json index 28586bd7..be2646a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@apify/datastructures": "^2.0.3", "@apify/log": "^2.5.16", "@modelcontextprotocol/sdk": "^1.18.1", + "@segment/analytics-node": "^2.3.0", "@types/cheerio": "^0.22.35", "@types/turndown": "^5.0.5", "ajv": "^8.17.1", @@ -1479,6 +1480,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -2628,6 +2650,45 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@segment/analytics-core": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.8.2.tgz", + "integrity": "sha512-5FDy6l8chpzUfJcNlIcyqYQq4+JTUynlVoCeCUuVz+l+6W0PXg+ljKp34R4yLVCcY5VVZohuW+HH0VLWdwYVAg==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-generic-utils": "1.2.0", + "dset": "^3.1.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-generic-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz", + "integrity": "sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-node/-/analytics-node-2.3.0.tgz", + "integrity": "sha512-fOXLL8uY0uAWw/sTLmezze80hj8YGgXXlAfvSS6TUmivk4D/SP0C0sxnbpFdkUzWg2zT64qWIZj26afEtSnxUA==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-core": "1.8.2", + "@segment/analytics-generic-utils": "1.2.0", + "buffer": "^6.0.3", + "jose": "^5.1.0", + "node-fetch": "^2.6.7", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@sindresorhus/is": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.1.tgz", @@ -3744,6 +3805,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3860,6 +3941,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4507,6 +4612,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6637,6 +6751,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -7040,7 +7163,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -8748,7 +8870,6 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/ts-api-utils": { @@ -9323,7 +9444,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/whatwg-encoding": { @@ -9363,7 +9483,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", diff --git a/package.json b/package.json index 1f882324..bf9037d0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "files": [ "dist", "LICENSE", + "package.json", "server.json", "manifest.json" ], @@ -42,6 +43,7 @@ "@apify/datastructures": "^2.0.3", "@apify/log": "^2.5.16", "@modelcontextprotocol/sdk": "^1.18.1", + "@segment/analytics-node": "^2.3.0", "@types/cheerio": "^0.22.35", "@types/turndown": "^5.0.5", "ajv": "^8.17.1", diff --git a/src/actor/server.ts b/src/actor/server.ts index 28a12188..9dec2393 100644 --- a/src/actor/server.ts +++ b/src/actor/server.ts @@ -14,6 +14,7 @@ import log from '@apify/log'; import { ApifyClient } from '../apify-client.js'; import { ActorsMcpServer } from '../mcp/server.js'; +import { parseBooleanFromString } from '../utils/generic.js'; import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js'; import { getActorRunData } from './utils.js'; @@ -78,7 +79,21 @@ export function createExpressApp( rt: Routes.SSE, tr: TransportType.SSE, }); - const mcpServer = new ActorsMcpServer({ setupSigintHandler: false }); + // Extract telemetry query parameters + const urlParams = new URL(req.url, `http://${req.headers.host}`).searchParams; + const telemetryEnabledParam = urlParams.get('telemetry-enabled'); + // URL param > env var > default (true) + const telemetryEnabled = parseBooleanFromString(telemetryEnabledParam) + ?? parseBooleanFromString(process.env.TELEMETRY_ENABLED) + ?? true; + + const mcpServer = new ActorsMcpServer({ + setupSigintHandler: false, + transportType: 'sse', + telemetry: { + enabled: telemetryEnabled, + }, + }); const transport = new SSEServerTransport(Routes.MESSAGE, res); // Load MCP server tools @@ -157,12 +172,27 @@ export function createExpressApp( // Reuse existing transport transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - use JSON response mode + // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: false, // Use SSE response mode }); - const mcpServer = new ActorsMcpServer({ setupSigintHandler: false, initializeRequestData: req.body as InitializeRequest }); + // Extract telemetry query parameters + const urlParams = new URL(req.url, `http://${req.headers.host}`).searchParams; + const telemetryEnabledParam = urlParams.get('telemetry-enabled'); + // URL param > env var > default (true) + const telemetryEnabled = parseBooleanFromString(telemetryEnabledParam) + ?? parseBooleanFromString(process.env.TELEMETRY_ENABLED) + ?? true; + + const mcpServer = new ActorsMcpServer({ + setupSigintHandler: false, + initializeRequestData: req.body as InitializeRequest, + transportType: 'http', + telemetry: { + enabled: telemetryEnabled, + }, + }); // Load MCP server tools const apifyToken = process.env.APIFY_TOKEN as string; diff --git a/src/const.ts b/src/const.ts index 346c274a..9887cc9c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -76,6 +76,8 @@ export const GET_HTML_SKELETON_CACHE_TTL_SECS = 5 * 60; // 5 minutes export const GET_HTML_SKELETON_CACHE_MAX_SIZE = 200; export const MCP_SERVER_CACHE_MAX_SIZE = 500; export const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes +export const USER_CACHE_MAX_SIZE = 200; +export const USER_CACHE_TTL_SECS = 60 * 60; // 1 hour export const ACTOR_PRICING_MODEL = { /** Rental Actors */ @@ -104,3 +106,21 @@ export const ALGOLIA = { export const PROGRESS_NOTIFICATION_INTERVAL_MS = 5_000; // 5 seconds export const APIFY_STORE_URL = 'https://apify.com'; + +// Telemetry +export const TELEMETRY_ENV = { + DEV: 'DEV', + PROD: 'PROD', +} as const; +export type TelemetryEnv = (typeof TELEMETRY_ENV)[keyof typeof TELEMETRY_ENV]; + +export const DEFAULT_TELEMETRY_ENABLED = true; +export const DEFAULT_TELEMETRY_ENV: TelemetryEnv = TELEMETRY_ENV.PROD; + +// We are using the same values as apify-core for consistency (despite that we ship events of different types). +// https://github.com/apify/apify-core/blob/2284766c122c6ac5bc4f27ec28051f4057d6f9c0/src/packages/analytics/src/server/segment.ts#L28 +// Reasoning from the apify-core: +// Flush at 50 events to avoid sending too many small requests (default is 15) +export const SEGMENT_FLUSH_AT_EVENTS = 50; +// Flush interval in milliseconds (default is 10000) +export const SEGMENT_FLUSH_INTERVAL_MS = 5_000; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 074b92fe..6f10614e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,37 +27,42 @@ import log from '@apify/log'; import { ApifyClient } from '../apify-client.js'; import { + DEFAULT_TELEMETRY_ENABLED, + DEFAULT_TELEMETRY_ENV, HelperTools, SERVER_NAME, SERVER_VERSION, SKYFIRE_PAY_ID_PROPERTY_DESCRIPTION, SKYFIRE_README_CONTENT, SKYFIRE_TOOL_INSTRUCTIONS, + type TelemetryEnv, } from '../const.js'; import { prompts } from '../prompts/index.js'; +import { getTelemetryEnv, trackToolCall } from '../telemetry.js'; import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } from '../tools/index.js'; import { decodeDotPropertyNames } from '../tools/utils.js'; -import type { ToolEntry } from '../types.js'; +import type { + ActorMcpTool, + ActorsMcpServerOptions, + ActorTool, + HelperTool, + ToolCallTelemetryProperties, + ToolEntry, +} from '../types.js'; import { buildActorResponseContent } from '../utils/actor-response.js'; +import { parseBooleanFromString } from '../utils/generic.js'; import { logHttpError } from '../utils/logging.js'; import { buildMCPResponse } from '../utils/mcp.js'; import { createProgressTracker } from '../utils/progress.js'; import { cloneToolEntry, getToolPublicFieldOnly } from '../utils/tools.js'; +import { getUserIdFromTokenCached } from '../utils/userid-cache.js'; +import { getPackageVersion } from '../utils/version.js'; import { connectMCPClient } from './client.js'; import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js'; import { processParamsGetTools } from './utils.js'; type ToolsChangedHandler = (toolNames: string[]) => void; -interface ActorsMcpServerOptions { - setupSigintHandler?: boolean; - /** - * Switch to enable Skyfire agentic payment mode. - */ - skyfireMode?: boolean; - initializeRequestData?: InitializeRequest; -} - /** * Create Apify MCP server */ @@ -69,8 +74,13 @@ export class ActorsMcpServer { private currentLogLevel = 'info'; public readonly options: ActorsMcpServerOptions; + // Telemetry configuration (resolved from options and env vars in setupTelemetry) + private telemetryEnabled: boolean | null = null; + private telemetryEnv: TelemetryEnv = DEFAULT_TELEMETRY_ENV; + constructor(options: ActorsMcpServerOptions = {}) { this.options = options; + const { setupSigintHandler = true } = options; this.server = new Server( { @@ -81,7 +91,7 @@ export class ActorsMcpServer { capabilities: { tools: { listChanged: true }, /** - * Declaring prompts even though we are not using them + * Declaring resources even though we are not using them * to prevent clients like Claude desktop from failing. */ resources: { }, @@ -90,6 +100,7 @@ export class ActorsMcpServer { }, }, ); + this.setupTelemetry(); this.setupLoggingProxy(); this.tools = new Map(); this.setupErrorHandling(setupSigintHandler); @@ -102,6 +113,24 @@ export class ActorsMcpServer { this.setupResourceHandlers(); } + /** + * Telemetry configuration with precedence: explicit options > env vars > defaults + */ + private setupTelemetry() { + const explicitEnabled = parseBooleanFromString(this.options.telemetry?.enabled); + if (explicitEnabled !== undefined) { + this.telemetryEnabled = explicitEnabled; + } else { + const envEnabled = parseBooleanFromString(process.env.TELEMETRY_ENABLED); + this.telemetryEnabled = envEnabled ?? DEFAULT_TELEMETRY_ENABLED; + } + + // Configure telemetryEnv: explicit option > env var > default ('PROD') + if (this.telemetryEnabled) { + this.telemetryEnv = getTelemetryEnv(this.options.telemetry?.env ?? process.env.TELEMETRY_ENV); + } + } + /** * Returns an array of tool names. * @returns {string[]} - An array of tool names. @@ -234,7 +263,7 @@ export class ActorsMcpServer { * Used primarily for SSE. */ public async loadToolsFromUrl(url: string, apifyClient: ApifyClient) { - const tools = await processParamsGetTools(url, apifyClient, this.options.initializeRequestData); + const tools = await processParamsGetTools(url, apifyClient); if (tools.length > 0) { log.debug('Loading tools from query parameters'); this.upsertTools(tools, false); @@ -263,20 +292,19 @@ export class ActorsMcpServer { * @returns Array of added/updated tool wrappers */ public upsertTools(tools: ToolEntry[], shouldNotifyToolsChangedHandler = false) { - // Handle Skyfire mode modifications before storing tools if (this.options.skyfireMode) { for (const wrap of tools) { - if (wrap.type === 'actor' - || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_CALL) - || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_OUTPUT_GET)) { - // Clone the tool before modifying it to avoid affecting shared objects - const clonedWrap = cloneToolEntry(wrap); + // Clone the tool before modifying it to avoid affecting shared objects + const clonedWrap = cloneToolEntry(wrap); + let modified = false; + // Handle Skyfire mode modifications + if (this.options.skyfireMode && (wrap.type === 'actor' + || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_CALL) + || (wrap.type === 'internal' && wrap.name === HelperTools.ACTOR_OUTPUT_GET))) { // Add Skyfire instructions to description if not already present if (clonedWrap.description && !clonedWrap.description.includes(SKYFIRE_TOOL_INSTRUCTIONS)) { clonedWrap.description += `\n\n${SKYFIRE_TOOL_INSTRUCTIONS}`; - } else if (!clonedWrap.description) { - clonedWrap.description = SKYFIRE_TOOL_INSTRUCTIONS; } // Add skyfire-pay-id property if not present if (clonedWrap.inputSchema && 'properties' in clonedWrap.inputSchema) { @@ -288,13 +316,11 @@ export class ActorsMcpServer { }; } } - - // Store the cloned and modified tool - this.tools.set(clonedWrap.name, clonedWrap); - } else { - // Store unmodified tools as-is - this.tools.set(wrap.name, wrap); + modified = true; } + + // Store the cloned and modified tool only if modifications were made + this.tools.set(clonedWrap.name, modified ? clonedWrap : wrap); } } else { // No skyfire mode - store tools as-is @@ -473,13 +499,16 @@ 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; + const apifyToken = (request.params.apifyToken || this.options.token || process.env.APIFY_TOKEN) as string; const userRentedActorIds = request.params.userRentedActorIds as string[] | undefined; - + // mcpSessionId was injected upstream by stdio; optional (for telemetry purposes only) + const mcpSessionId = typeof request.params.mcpSessionId === 'string' ? request.params.mcpSessionId : undefined; // Remove apifyToken from request.params just in case delete request.params.apifyToken; // Remove other custom params passed from apify-mcp-server delete request.params.userRentedActorIds; + // Remove mcpSessionId + delete request.params.mcpSessionId; // Validate token if (!apifyToken && !this.options.skyfireMode) { @@ -547,6 +576,10 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool msg, ); } + const { telemetryData, userId } = await this.prepareTelemetryData(tool, mcpSessionId, apifyToken); + + const startTime = Date.now(); + let toolStatus: 'succeeded' | 'failed' | 'aborted' = 'succeeded'; try { // Handle internal tool @@ -570,7 +603,7 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool if (progressTracker) { progressTracker.stop(); } - + toolStatus = ('isError' in res && res.isError) ? 'failed' : 'succeeded'; return { ...res }; } @@ -583,6 +616,7 @@ Please check the tool's input schema using ${HelperTools.ACTOR_GET_DETAILS} tool 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 }); + toolStatus = 'failed'; return buildMCPResponse([msg], true); } @@ -621,9 +655,7 @@ Please verify the server URL is correct and accessible, and ensure you have a va // Handle actor tool if (tool.type === 'actor') { - if (this.options.skyfireMode - && args['skyfire-pay-id'] === undefined - ) { + if (this.options.skyfireMode && args['skyfire-pay-id'] === undefined) { return buildMCPResponse([SKYFIRE_TOOL_INSTRUCTIONS]); } @@ -652,6 +684,7 @@ Please verify the server URL is correct and accessible, and ensure you have a va ); if (!callResult) { + toolStatus = 'aborted'; // Receivers of cancellation notifications SHOULD NOT send a response for the cancelled request // https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#behavior-requirements return { }; @@ -666,12 +699,15 @@ Please verify the server URL is correct and accessible, and ensure you have a va } } } catch (error) { + toolStatus = extra.signal?.aborted ? 'aborted' : 'failed'; 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}. Please verify the tool name, input parameters, and ensure all required resources are available.`, ], true); + } finally { + this.finalizeAndTrackTelemetry(telemetryData, userId, startTime, toolStatus); } const availableTools = this.listToolNames(); @@ -690,6 +726,72 @@ Please verify the tool name and ensure the tool is properly registered.`; }); } + /** + * Finalizes and tracks telemetry for a tool call. + * Calculates execution time, sets final status, and sends the telemetry event. + * + * @param telemetryData - Telemetry data to finalize and track (null if telemetry is disabled) + * @param userId - Apify user ID (string or null if not available) + * @param startTime - Timestamp when the tool call started + * @param toolStatus - Final status of the tool call ('succeeded', 'failed', or 'aborted') + */ + private finalizeAndTrackTelemetry( + telemetryData: ToolCallTelemetryProperties | null, + userId: string | null, + startTime: number, + toolStatus: 'succeeded' | 'failed' | 'aborted', + ): void { + if (!telemetryData) { + return; + } + + const execTime = Date.now() - startTime; + const finalizedTelemetryData: ToolCallTelemetryProperties = { + ...telemetryData, + tool_status: toolStatus, + tool_exec_time_ms: execTime, + }; + trackToolCall(userId, this.telemetryEnv, finalizedTelemetryData); + } + + /* + * Creates telemetry data for a tool call. + */ + private async prepareTelemetryData( + tool: HelperTool | ActorTool | ActorMcpTool, mcpSessionId: string | undefined, apifyToken: string, + ): Promise<{ telemetryData: ToolCallTelemetryProperties | null; userId: string | null }> { + if (!this.telemetryEnabled) { + return { telemetryData: null, userId: null }; + } + + const toolFullName = tool.type === 'actor' ? tool.actorFullName : tool.name; + + // Get userId from cache or fetch from API + let userId: string | null = null; + if (apifyToken) { + const apifyClient = new ApifyClient({ token: apifyToken }); + userId = await getUserIdFromTokenCached(apifyToken, apifyClient); + log.debug('Telemetry: fetched userId', { userId }); + } + const capabilities = this.options.initializeRequestData?.params?.capabilities; + const params = this.options.initializeRequestData?.params as InitializeRequest['params']; + const telemetryData: ToolCallTelemetryProperties = { + app: 'mcp', + app_version: getPackageVersion() || '', + mcp_client_name: params?.clientInfo?.name || '', + mcp_client_version: params?.clientInfo?.version || '', + mcp_protocol_version: params?.protocolVersion || '', + mcp_client_capabilities: capabilities ? JSON.stringify(capabilities) : '', + mcp_session_id: mcpSessionId || '', + transport_type: this.options.transportType || '', + tool_name: toolFullName, + tool_status: 'succeeded', // Will be updated in finally + tool_exec_time_ms: 0, // Will be calculated in finally + }; + + return { telemetryData, userId }; + } + async connect(transport: Transport): Promise { await this.server.connect(transport); } diff --git a/src/mcp/utils.ts b/src/mcp/utils.ts index 2aa8764c..78a773b2 100644 --- a/src/mcp/utils.ts +++ b/src/mcp/utils.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import { parse } from 'node:querystring'; -import type { InitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { ApifyClient } from 'apify-client'; import { processInput } from '../input.js'; @@ -41,11 +40,10 @@ export function getProxyMCPServerToolName(url: string, toolName: string): string * If URL contains query parameter `actors`, return tools from Actors otherwise return null. * @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) { +export async function processParamsGetTools(url: string, apifyClient: ApifyClient) { const input = parseInputParamsFromUrl(url); - return await loadToolsFromInput(input, apifyClient, initializeRequestData); + return await loadToolsFromInput(input, apifyClient); } export function parseInputParamsFromUrl(url: string): Input { diff --git a/src/stdio.ts b/src/stdio.ts index d2e2621a..ec9875c4 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -13,7 +13,13 @@ * node stdio.js --actors=apify/google-search-scraper,apify/instagram-scraper */ +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import yargs from 'yargs'; // Had to ignore the eslint import extension error for the yargs package. // Using .js or /index.js didn't resolve it due to the @types package issues. @@ -23,8 +29,10 @@ import { hideBin } from 'yargs/helpers'; import log from '@apify/log'; import { ApifyClient } from './apify-client.js'; +import { DEFAULT_TELEMETRY_ENV, TELEMETRY_ENV, type TelemetryEnv } from './const.js'; import { processInput } from './input.js'; import { ActorsMcpServer } from './mcp/server.js'; +import { getTelemetryEnv } from './telemetry.js'; import type { Input, ToolSelector } from './types.js'; import { parseCommaSeparatedList } from './utils/generic.js'; import { loadToolsFromInput } from './utils/tools-loader.js'; @@ -41,6 +49,25 @@ interface CliArgs { enableActorAutoLoading: boolean; /** Tool categories to include */ tools?: string; + /** Enable or disable telemetry tracking (default: true) */ + telemetryEnabled: boolean; + /** Telemetry environment: 'PROD' or 'DEV' (default: 'PROD', only used when telemetry-enabled is true) */ + telemetryEnv: TelemetryEnv; +} + +/** + * Attempts to read Apify token from ~/.apify/auth.json file + * Returns the token if found, undefined otherwise + */ +function getTokenFromAuthFile(): string | undefined { + try { + const authPath = join(homedir(), '.apify', 'auth.json'); + const content = readFileSync(authPath, 'utf-8'); + const authData = JSON.parse(content); + return authData.token || undefined; + } catch { + return undefined; + } } // Configure logging, set to ERROR @@ -60,13 +87,13 @@ const argv = yargs(hideBin(process.argv)) type: 'boolean', default: false, describe: `Enable dynamically adding Actors as tools based on user requests. Can also be set via ENABLE_ADDING_ACTORS environment variable. -Deprecated: use tools experimental category instead.`, +Deprecated: use tools add-actor instead.`, }) .option('enableActorAutoLoading', { type: 'boolean', default: false, hidden: true, - describe: 'Deprecated: use enable-adding-actors instead.', + describe: 'Deprecated: Use tools add-actor instead.', }) .options('tools', { type: 'string', @@ -75,6 +102,23 @@ Deprecated: use tools experimental category instead.`, For more details visit https://mcp.apify.com`, example: 'actors,docs,apify/rag-web-browser', }) + .option('telemetry-enabled', { + type: 'boolean', + default: true, + describe: `Enable or disable telemetry tracking for tool calls. Can also be set via TELEMETRY_ENABLED environment variable. +Default: true (enabled)`, + }) + .option('telemetry-env', { + type: 'string', + choices: [TELEMETRY_ENV.PROD, TELEMETRY_ENV.DEV], + default: DEFAULT_TELEMETRY_ENV, + hidden: true, + coerce: (arg: string) => arg?.toUpperCase(), + describe: `Telemetry environment when telemetry is enabled. Can also be set via TELEMETRY_ENV environment variable. +- 'PROD': Send events to production Segment workspace (default) +- 'DEV': Send events to development Segment workspace +Only used when --telemetry-enabled is true`, + }) .help('help') .alias('h', 'help') .version(false) @@ -100,14 +144,24 @@ log.error = (...args: Parameters) => { console.error(...args); }; +// Get token from environment or auth file +const apifyToken = process.env.APIFY_TOKEN || getTokenFromAuthFile(); + // Validate environment -if (!process.env.APIFY_TOKEN) { - log.error('APIFY_TOKEN is required but not set in the environment variables.'); +if (!apifyToken) { + log.error('APIFY_TOKEN is required but not set in the environment variables or in ~/.apify/auth.json'); process.exit(1); } async function main() { - const mcpServer = new ActorsMcpServer(); + const mcpServer = new ActorsMcpServer({ + transportType: 'stdio', + telemetry: { + enabled: argv.telemetryEnabled, + env: getTelemetryEnv(argv.telemetryEnv), + }, + token: apifyToken, + }); // Create an Input object from CLI arguments const input: Input = { @@ -119,7 +173,7 @@ async function main() { // Normalize (merges actors into tools for backward compatibility) const normalizedInput = processInput(input); - const apifyClient = new ApifyClient({ token: process.env.APIFY_TOKEN }); + const apifyClient = new ApifyClient({ token: apifyToken }); // Use the shared tools loading logic const tools = await loadToolsFromInput(normalizedInput, apifyClient); @@ -127,6 +181,35 @@ async function main() { // Start server const transport = new StdioServerTransport(); + + // Generate a unique session ID for this stdio connection + // Note: stdio transport does not have a strict session ID concept like HTTP transports, + // so we generate a UUID4 to represent this single session interaction for telemetry tracking + const mcpSessionId = randomUUID(); + + // Create a proxy for transport.onmessage to intercept and capture initialize request data + // This is a hacky way to inject client information into the ActorsMcpServer class + const originalOnMessage = transport.onmessage; + + transport.onmessage = (message: JSONRPCMessage) => { + // Extract client information from initialize message + const msgRecord = message as Record; + if (msgRecord.method === 'initialize') { + // Update mcpServer options with initialize request data + (mcpServer.options as Record).initializeRequestData = msgRecord as Record; + } + // Inject session ID into tool call messages + if (msgRecord.method === 'tools/call' && msgRecord.params) { + const params = msgRecord.params as Record; + params.mcpSessionId = mcpSessionId; + } + + // Call the original onmessage handler + if (originalOnMessage) { + originalOnMessage(message); + } + }; + await mcpServer.connect(transport); } diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 00000000..3e46964f --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,89 @@ +import * as crypto from 'node:crypto'; + +import { Analytics } from '@segment/analytics-node'; + +import log from '@apify/log'; + +import { + DEFAULT_TELEMETRY_ENV, + SEGMENT_FLUSH_AT_EVENTS, + SEGMENT_FLUSH_INTERVAL_MS, + TELEMETRY_ENV, + type TelemetryEnv, +} from './const.js'; +import type { ToolCallTelemetryProperties } from './types.js'; + +const DEV_WRITE_KEY = '9rPHlMtxX8FJhilGEwkfUoZ0uzWxnzcT'; +const PROD_WRITE_KEY = 'cOkp5EIJaN69gYaN8bcp7KtaD0fGABwJ'; + +// Event names following apify-core naming convention (Title Case) +const SEGMENT_EVENTS = { + TOOL_CALL: 'MCP Tool Call', +} as const; + +/** + * Gets the telemetry environment, defaulting to 'PROD' if not provided or invalid + */ +export function getTelemetryEnv(env?: string | null): TelemetryEnv { + if (!env) { + return DEFAULT_TELEMETRY_ENV; + } + const normalizedEnv = env.toUpperCase(); + if (normalizedEnv === TELEMETRY_ENV.DEV || normalizedEnv === TELEMETRY_ENV.PROD) { + return normalizedEnv as TelemetryEnv; + } + return DEFAULT_TELEMETRY_ENV; +} + +// Single Segment Analytics client (environment determined by process.env.TELEMETRY_ENV) +let analyticsClient: Analytics | null = null; + +/** + * Gets or initializes the Segment Analytics client. + * The environment is determined by the TELEMETRY_ENV environment variable. + * + * @returns Analytics client instance or null if initialization failed + */ +export function getOrInitAnalyticsClient(telemetryEnv: TelemetryEnv): Analytics | null { + if (!analyticsClient) { + try { + const writeKey = telemetryEnv === TELEMETRY_ENV.PROD ? PROD_WRITE_KEY : DEV_WRITE_KEY; + analyticsClient = new Analytics({ + writeKey, + flushAt: SEGMENT_FLUSH_AT_EVENTS, + flushInterval: SEGMENT_FLUSH_INTERVAL_MS, + }); + } catch (error) { + log.error('Segment initialization failed', { error }); + return null; + } + } + return analyticsClient; +} + +/** + * Tracks a tool call event to Segment. + * Segment requires either userId OR anonymousId, but not both + * When userId is available, use it; otherwise use anonymousId + * + * @param userId - Apify user ID (null if not available) + * @param telemetryEnv - Telemetry environment + * @param properties - Event properties for the tool call + */ +export function trackToolCall( + userId: string | null, + telemetryEnv: TelemetryEnv, + properties: ToolCallTelemetryProperties, +): void { + const client = getOrInitAnalyticsClient(telemetryEnv); + + try { + client?.track({ + ...(userId ? { userId } : { anonymousId: crypto.randomUUID() }), + event: SEGMENT_EVENTS.TOOL_CALL, + properties, + }); + } catch (error) { + log.error('Failed to track tool call event', { error, userId, toolName: properties.tool_name }); + } +} diff --git a/src/types.ts b/src/types.ts index 2094d15d..d660d460 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,11 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; -import type { Notification, Prompt, Request, ToolSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { InitializeRequest, Notification, Prompt, Request, ToolSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ActorDefaultRunOptions, ActorDefinition, ActorStoreList, PricingInfo } from 'apify-client'; import type z from 'zod'; -import type { ACTOR_PRICING_MODEL } from './const.js'; +import type { ACTOR_PRICING_MODEL, TelemetryEnv } from './const.js'; import type { ActorsMcpServer } from './mcp/server.js'; import type { toolCategories } from './tools/index.js'; import type { ProgressTracker } from './utils/progress.js'; @@ -293,3 +293,62 @@ export type DatasetItem = Record; * Can be null or undefined in case of Skyfire requests. */ export type ApifyToken = string | null | undefined; + +/** + * Properties for tool call telemetry events sent to Segment. + */ +export interface ToolCallTelemetryProperties { + app: 'mcp'; + app_version: string; + mcp_client_name: string; + mcp_client_version: string; + mcp_protocol_version: string; + mcp_client_capabilities: string; + mcp_session_id: string; + transport_type: string; + tool_name: string; + tool_status: 'succeeded' | 'failed' | 'aborted'; + tool_exec_time_ms: number; +} + +/** + * Options for configuring the ActorsMcpServer instance. + */ +export interface ActorsMcpServerOptions { + setupSigintHandler?: boolean; + /** + * Switch to enable Skyfire agentic payment mode. + */ + skyfireMode?: boolean; + initializeRequestData?: InitializeRequest; + /** + * Telemetry configuration options. + */ + telemetry?: { + /** + * Enable or disable telemetry tracking for tool calls. + * Must be explicitly set when telemetry object is provided. + * When telemetry object is omitted entirely, defaults to true (via env var or default). + */ + enabled: boolean; + /** + * Telemetry environment when telemetry is enabled. + * - 'DEV': Use development Segment write key + * - 'PROD': Use production Segment write key (default) + */ + env?: TelemetryEnv; + }; + /** + * Transport type for telemetry tracking. + * - 'stdio': Direct/local stdio connection + * - 'http': Remote HTTP streamable connection + * - 'sse': Remote Server-Sent Events (SSE) connection + */ + transportType?: 'stdio' | 'http' | 'sse'; + /** + * Apify API token for authentication + * Primarily used by stdio transport when token is read from ~/.apify/auth.json file + * instead of APIFY_TOKEN environment variable, so it can be passed to the server + */ + token?: string; +} diff --git a/src/utils/generic.ts b/src/utils/generic.ts index 3757cb74..2f8e09d8 100644 --- a/src/utils/generic.ts +++ b/src/utils/generic.ts @@ -65,3 +65,32 @@ export function isValidHttpUrl(urlString: string): boolean { return false; } } + +/** + * Parses a boolean value from a string, boolean, null, or undefined. + * Accepts 'true', '1' as true, 'false', '0' as false. + * If value is already a boolean, returns it directly. + * Returns undefined if the value is not a recognized boolean string or is null/undefined/empty string. + */ +export function parseBooleanFromString(value: string | boolean | undefined | null): boolean | undefined { + // If already a boolean, return it directly + if (typeof value === 'boolean') { + return value; + } + // Handle undefined/null + if (value === undefined || value === null) { + return undefined; + } + // Handle empty string (after trim) + const normalized = value.toLowerCase().trim(); + if (normalized === '') { + return undefined; + } + if (normalized === 'true' || normalized === '1') { + return true; + } + if (normalized === 'false' || normalized === '0') { + return false; + } + return undefined; +} diff --git a/src/utils/tools-loader.ts b/src/utils/tools-loader.ts index 57e5b563..90ccac64 100644 --- a/src/utils/tools-loader.ts +++ b/src/utils/tools-loader.ts @@ -3,7 +3,6 @@ * This eliminates duplication between stdio.ts and processParamsGetTools. */ -import type { InitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { ValidateFunction } from 'ajv'; import type { ApifyClient } from 'apify'; @@ -35,13 +34,11 @@ function getInternalToolByNameMap(): Map { * * @param input The processed Input object * @param apifyClient The Apify client instance - * @param _initializeRequestData Optional initialize request data * @returns An array of tool entries */ export async function loadToolsFromInput( input: Input, apifyClient: ApifyClient, - _initializeRequestData?: InitializeRequest, ): Promise { // Helpers for readability const normalizeSelectors = (value: Input['tools']): (string | ToolCategory)[] | undefined => { diff --git a/src/utils/userid-cache.ts b/src/utils/userid-cache.ts new file mode 100644 index 00000000..685ea326 --- /dev/null +++ b/src/utils/userid-cache.ts @@ -0,0 +1,33 @@ +import { createHash } from 'node:crypto'; + +import type { ApifyClient } from '../apify-client.js'; +import { USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS } from '../const.js'; +import { TTLLRUCache } from './ttl-lru.js'; + +// LRU cache with TTL for user info - stores the raw User object from API +const userIdCache = new TTLLRUCache(USER_CACHE_MAX_SIZE, USER_CACHE_TTL_SECS); + +/** + * Gets user ID from token, using cache to avoid repeated API calls + * Token is hashed before caching to avoid storing raw tokens + * Returns userId or null if not found + */ +export async function getUserIdFromTokenCached( + token: string, + apifyClient: ApifyClient, +): Promise { + const tokenHash = createHash('sha256').update(token).digest('hex'); + const cachedId = userIdCache.get(tokenHash); + if (cachedId) return cachedId; + + try { + const user = await apifyClient.user('me').get(); + if (!user || !user.id) { + return null; + } + userIdCache.set(tokenHash, user.id); + return user.id; + } catch { + return null; + } +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 00000000..7ab1259a --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,12 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const packageJson = require('../../package.json'); + +/** + * Gets the package version from package.json + * Returns null if version is not available + */ +export function getPackageVersion(): string | null { + return packageJson.version || null; +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 1fc5d2ac..5dc61142 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -4,7 +4,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { expect } from 'vitest'; -import { HelperTools } from '../src/const.js'; +import { HelperTools, type TelemetryEnv } from '../src/const.js'; import type { ToolCategory } from '../src/types.js'; export interface McpClientOptions { @@ -13,17 +13,20 @@ export interface McpClientOptions { tools?: (ToolCategory | string)[]; // Tool categories, specific tool or Actor names to include useEnv?: boolean; // Use environment variables instead of command line arguments (stdio only) clientName?: string; // Client name for identification + telemetry?: { + enabled?: boolean; // Enable or disable telemetry (default: false for tests) + env?: TelemetryEnv; // Telemetry environment (default: 'PROD', only used when telemetry.enabled is true) + }; } -export async function createMcpSseClient( - serverUrl: string, - options?: McpClientOptions, -): Promise { +function checkApifyToken(): void { if (!process.env.APIFY_TOKEN) { throw new Error('APIFY_TOKEN environment variable is not set.'); } - const url = new URL(serverUrl); - const { actors, enableAddingActors, tools } = options || {}; +} + +function appendSearchParams(url: URL, options?: McpClientOptions): void { + const { actors, enableAddingActors, tools, telemetry } = options || {}; if (actors !== undefined) { url.searchParams.append('actors', actors.join(',')); } @@ -33,6 +36,18 @@ export async function createMcpSseClient( if (tools !== undefined) { url.searchParams.append('tools', tools.join(',')); } + // Append telemetry parameters (default to false for tests when not explicitly set) + const telemetryEnabled = telemetry?.enabled !== undefined ? telemetry.enabled : false; + url.searchParams.append('telemetry-enabled', telemetryEnabled.toString()); +} + +export async function createMcpSseClient( + serverUrl: string, + options?: McpClientOptions, +): Promise { + checkApifyToken(); + const url = new URL(serverUrl); + appendSearchParams(url, options); const transport = new SSEClientTransport( url, @@ -58,20 +73,9 @@ export async function createMcpStreamableClient( serverUrl: string, options?: McpClientOptions, ): Promise { - if (!process.env.APIFY_TOKEN) { - throw new Error('APIFY_TOKEN environment variable is not set.'); - } + checkApifyToken(); const url = new URL(serverUrl); - const { actors, enableAddingActors, tools } = options || {}; - if (actors !== undefined) { - url.searchParams.append('actors', actors.join(',')); - } - if (enableAddingActors !== undefined) { - url.searchParams.append('enableAddingActors', enableAddingActors.toString()); - } - if (tools !== undefined) { - url.searchParams.append('tools', tools.join(',')); - } + appendSearchParams(url, options); const transport = new StreamableHTTPClientTransport( url, @@ -96,10 +100,8 @@ export async function createMcpStreamableClient( export async function createMcpStdioClient( options?: McpClientOptions, ): Promise { - if (!process.env.APIFY_TOKEN) { - throw new Error('APIFY_TOKEN environment variable is not set.'); - } - const { actors, enableAddingActors, tools, useEnv } = options || {}; + checkApifyToken(); + const { actors, enableAddingActors, tools, useEnv, telemetry } = options || {}; const args = ['dist/stdio.js']; const env: Record = { APIFY_TOKEN: process.env.APIFY_TOKEN as string, @@ -116,6 +118,12 @@ export async function createMcpStdioClient( if (tools !== undefined) { env.TOOLS = tools.join(','); } + if (telemetry?.enabled !== undefined) { + env.TELEMETRY_ENABLED = telemetry.enabled.toString(); + } + if (telemetry?.env !== undefined) { + env.TELEMETRY_ENV = telemetry.env; + } } else { // Use command line arguments as before if (actors !== undefined) { @@ -127,6 +135,12 @@ export async function createMcpStdioClient( if (tools !== undefined) { args.push('--tools', tools.join(',')); } + if (telemetry?.enabled === false) { + args.push('--telemetry-enabled', 'false'); + } + if (telemetry?.env !== undefined && telemetry?.enabled !== false) { + args.push('--telemetry-env', telemetry.env); + } } const transport = new StdioClientTransport({ diff --git a/tests/integration/actor.server-sse.test.ts b/tests/integration/actor.server-sse.test.ts deleted file mode 100644 index a75408d7..00000000 --- a/tests/integration/actor.server-sse.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Server as HttpServer } from 'node:http'; - -import type { Express } from 'express'; - -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; -let httpServerPort: number; -let httpServerHost: string; -let mcpUrl: string; - -createIntegrationTestsSuite({ - suiteName: 'Apify MCP Server SSE', - transport: 'sse', - createClientFn: async (options) => await createMcpSseClient(mcpUrl, options), - 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); - - // Start a test server - await new Promise((resolve) => { - httpServer = app.listen(httpServerPort, () => resolve()); - }); - }, - afterAllFn: async () => { - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - }, -}); diff --git a/tests/integration/actor.server-streamable.test.ts b/tests/integration/actor.server-streamable.test.ts deleted file mode 100644 index c21923b3..00000000 --- a/tests/integration/actor.server-streamable.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Server as HttpServer } from 'node:http'; - -import type { Express } from 'express'; - -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; -let httpServerPort: number; -let httpServerHost: string; -let mcpUrl: string; - -createIntegrationTestsSuite({ - suiteName: 'Apify MCP Server Streamable HTTP', - transport: 'streamable-http', - createClientFn: async (options) => await createMcpStreamableClient(mcpUrl, options), - 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); - - // Start a test server - await new Promise((resolve) => { - httpServer = app.listen(httpServerPort, () => resolve()); - }); - }, - afterAllFn: async () => { - await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); - }, -}); diff --git a/tests/integration/suite.ts b/tests/integration/suite.ts index e3654036..7e295a3d 100644 --- a/tests/integration/suite.ts +++ b/tests/integration/suite.ts @@ -1063,5 +1063,16 @@ export function createIntegrationTestsSuite( await client.close(); }); + + // Environment variable precedence tests + it.runIf(options.transport === 'stdio')('should use TELEMETRY_ENABLED env var when CLI arg is not provided', async () => { + // When useEnv=true, telemetry.enabled option translates to env.TELEMETRY_ENABLED in child process + client = await createClientFn({ useEnv: true, telemetry: { enabled: false } }); + const tools = await client.listTools(); + + // Verify tools are loaded correctly + expect(tools.tools.length).toBeGreaterThan(0); + await client.close(); + }); }); } diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts new file mode 100644 index 00000000..5a10535a --- /dev/null +++ b/tests/unit/telemetry.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { trackToolCall } from '../../src/telemetry.js'; + +// Mock the Segment Analytics client +const mockTrack = vi.fn(); +vi.mock('@segment/analytics-node', () => ({ + Analytics: vi.fn().mockImplementation(() => ({ + track: mockTrack, + })), +})); + +describe('telemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should send correct payload structure to Segment with userId', () => { + const userId = 'test-user-123'; + const properties = { + app: 'mcp' as const, + app_version: '0.5.6', + mcp_client_name: 'test-client', + mcp_client_version: '1.0.0', + mcp_protocol_version: '2024-11-05', + mcp_client_capabilities: '{}', + mcp_session_id: 'session-123', + transport_type: 'stdio', + tool_name: 'test-tool', + tool_status: 'succeeded' as const, + tool_exec_time_ms: 100, + }; + + trackToolCall(userId, 'DEV', properties); + + expect(mockTrack).toHaveBeenCalledWith({ + userId: 'test-user-123', + event: 'MCP Tool Call', + properties: { + app: 'mcp', + app_version: '0.5.6', + mcp_client_name: 'test-client', + mcp_client_version: '1.0.0', + mcp_protocol_version: '2024-11-05', + mcp_client_capabilities: '{}', + mcp_session_id: 'session-123', + transport_type: 'stdio', + tool_name: 'test-tool', + tool_status: 'succeeded', + tool_exec_time_ms: 100, + }, + }); + }); + + it('should use anonymousId when userId is null', () => { + const properties = { + app: 'mcp' as const, + app_version: '0.5.6', + mcp_client_name: 'test-client', + mcp_client_version: '1.0.0', + mcp_protocol_version: '2024-11-05', + mcp_client_capabilities: '{}', + mcp_session_id: 'session-123', + transport_type: 'stdio', + tool_name: 'test-tool', + tool_status: 'succeeded' as const, + tool_exec_time_ms: 100, + }; + + trackToolCall(null, 'DEV', properties); + + expect(mockTrack).toHaveBeenCalledTimes(1); + const callArgs = mockTrack.mock.calls[0][0]; + + // Should have anonymousId but not userId + expect(callArgs).toHaveProperty('anonymousId'); + expect(callArgs.anonymousId).toBeDefined(); + expect(typeof callArgs.anonymousId).toBe('string'); + expect(callArgs.anonymousId.length).toBeGreaterThan(0); + expect(callArgs).not.toHaveProperty('userId'); + expect(callArgs.event).toBe('MCP Tool Call'); + expect(callArgs.properties).toEqual(properties); + }); +}); diff --git a/tests/unit/utils.generic.test.ts b/tests/unit/utils.generic.test.ts index 965712a5..dc1f9af2 100644 --- a/tests/unit/utils.generic.test.ts +++ b/tests/unit/utils.generic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getValuesByDotKeys, isValidHttpUrl, parseCommaSeparatedList } from '../../src/utils/generic.js'; +import { getValuesByDotKeys, isValidHttpUrl, parseBooleanFromString, parseCommaSeparatedList } from '../../src/utils/generic.js'; describe('getValuesByDotKeys', () => { it('should get value for a key without dot', () => { @@ -102,3 +102,49 @@ describe('isValidUrl', () => { expect(isValidHttpUrl('://example.com')).toBe(false); }); }); + +describe('parseBooleanFromString', () => { + it('should return boolean values directly', () => { + expect(parseBooleanFromString(true)).toBe(true); + expect(parseBooleanFromString(false)).toBe(false); + }); + + it('should parse "true" and "1" as true', () => { + expect(parseBooleanFromString('true')).toBe(true); + expect(parseBooleanFromString('TRUE')).toBe(true); + expect(parseBooleanFromString('True')).toBe(true); + expect(parseBooleanFromString('1')).toBe(true); + expect(parseBooleanFromString(' true ')).toBe(true); + expect(parseBooleanFromString(' 1 ')).toBe(true); + }); + + it('should parse "false" and "0" as false', () => { + expect(parseBooleanFromString('false')).toBe(false); + expect(parseBooleanFromString('FALSE')).toBe(false); + expect(parseBooleanFromString('False')).toBe(false); + expect(parseBooleanFromString('0')).toBe(false); + expect(parseBooleanFromString(' false ')).toBe(false); + expect(parseBooleanFromString(' 0 ')).toBe(false); + }); + + it('should return undefined for null and undefined', () => { + expect(parseBooleanFromString(null)).toBeUndefined(); + expect(parseBooleanFromString(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty strings', () => { + expect(parseBooleanFromString('')).toBeUndefined(); + expect(parseBooleanFromString(' ')).toBeUndefined(); + expect(parseBooleanFromString('\t')).toBeUndefined(); + expect(parseBooleanFromString('\n')).toBeUndefined(); + }); + + it('should return undefined for unrecognized strings', () => { + expect(parseBooleanFromString('yes')).toBeUndefined(); + expect(parseBooleanFromString('no')).toBeUndefined(); + expect(parseBooleanFromString('2')).toBeUndefined(); + expect(parseBooleanFromString('maybe')).toBeUndefined(); + expect(parseBooleanFromString('on')).toBeUndefined(); + expect(parseBooleanFromString('off')).toBeUndefined(); + }); +});