diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 65149d06..098b04d3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,7 +1,7 @@ name: Bug report description: Report a bug or issue with the Apify MCP server title: "[Bug]: " -labels: ["bug"] +labels: ["bug","t-ai"] body: - type: dropdown id: server-type diff --git a/.github/workflows/_smoke_test_npm_package.yaml b/.github/workflows/_smoke_test_npm_package.yaml new file mode 100644 index 00000000..ad5212ec --- /dev/null +++ b/.github/workflows/_smoke_test_npm_package.yaml @@ -0,0 +1,51 @@ +name: Smoke test npm package + +on: + workflow_call: + inputs: + npm_tag: + description: NPM dist-tag to install (e.g. "beta", "latest"). Ignored when version is set. + required: false + type: string + default: latest + version: + description: Exact version to install (e.g. "1.2.3"). Takes precedence over npm_tag. + required: false + type: string + default: "" + +jobs: + smoke_test: + name: Smoke test published package + runs-on: ubuntu-latest + steps: + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install published package + uses: nick-fields/retry@v4 + env: + PKG_VERSION: ${{ inputs.version || inputs.npm_tag }} + with: + timeout_minutes: 5 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + mkdir -p smoke-test && cd smoke-test + npm init -y + npm install "@apify/actors-mcp-server@${PKG_VERSION}" @apify/mcpc + + - name: Smoke test MCP server via mcpc + working-directory: smoke-test + run: | + echo '{"mcpServers":{"dev":{"command":"npx","args":["actors-mcp-server","--tools","docs,search-actors,fetch-actor-details","--telemetry-enabled","false"]}}}' > .mcp.json + npx mcpc connect .mcp.json:dev @dev + npx mcpc @dev ping + npx mcpc --json @dev tools-list | jq -e 'length > 0' + npx mcpc --json @dev tools-call search-actors keywords:="web scraper" | jq -e '.content[0].text' + npx mcpc --json @dev tools-call fetch-actor-details actor:="apify/web-scraper" | jq -e '.content[0].text' + npx mcpc --json @dev tools-call search-apify-docs query:="web scraping" docSource:="apify" | jq -e '.content[0].text' + npx mcpc --json @dev tools-call fetch-apify-docs url:="https://docs.apify.com/platform/actors" | jq -e '.content[0].text' + npx mcpc @dev close diff --git a/.github/workflows/manual_release_stable.yaml b/.github/workflows/manual_release_stable.yaml index 916a7c12..603d9e06 100644 --- a/.github/workflows/manual_release_stable.yaml +++ b/.github/workflows/manual_release_stable.yaml @@ -119,6 +119,13 @@ jobs: "tag": "latest" } + smoke_test: + name: Smoke test published package + needs: [ release_metadata, publish_to_npm ] + uses: ./.github/workflows/_smoke_test_npm_package.yaml + with: + version: ${{ needs.release_metadata.outputs.version_number }} + bump_dependency_in_internal_repo: name: Bump dependency in apify-mcp-server-internal needs: [ release_metadata, publish_to_npm ] diff --git a/.github/workflows/on_master.yaml b/.github/workflows/on_master.yaml index c9ca9dd6..5e41cc82 100644 --- a/.github/workflows/on_master.yaml +++ b/.github/workflows/on_master.yaml @@ -46,3 +46,10 @@ jobs: "ref": "${{ needs.release_metadata.outputs.changelog_commitish }}", "tag": "beta" } + + smoke_test: + name: Smoke test published package + needs: [ publish_to_npm ] + uses: ./.github/workflows/_smoke_test_npm_package.yaml + with: + npm_tag: beta diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a95bc5..3cb47a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,11 @@ All notable changes to this project will be documented in this file. -## 0.9.18 - **not yet released** +## 0.9.19 - **not yet released** + + + +## [0.9.18](https://github.com/apify/apify-mcp-server/releases/tag/v0.9.18) (2026-04-14) ### 🚀 Features @@ -16,9 +20,9 @@ All notable changes to this project will be documented in this file. - Sanitize all env values in evals to prevent ERR_INVALID_CHAR ([#665](https://github.com/apify/apify-mcp-server/pull/665)) ([9f0b9d9](https://github.com/apify/apify-mcp-server/commit/9f0b9d9d07a68999a26f3ed2e439fac47a872863)) by [@jirispilka](https://github.com/jirispilka) - Actor run cancellation handling and add tests for abort scenarios ([#662](https://github.com/apify/apify-mcp-server/pull/662)) ([acedbf5](https://github.com/apify/apify-mcp-server/commit/acedbf59b374a1243bb31d2930ebac8403b7c47e)) by [@jirispilka](https://github.com/jirispilka) - DNS rebinding protection for dev server ([#653](https://github.com/apify/apify-mcp-server/pull/653)) ([9781b45](https://github.com/apify/apify-mcp-server/commit/9781b450a53c96e0df49f463881253507502f195)) by [@jirispilka](https://github.com/jirispilka) +- Fix error in the validation pipeline ([#669](https://github.com/apify/apify-mcp-server/pull/669)) ([b844f31](https://github.com/apify/apify-mcp-server/commit/b844f31f4ccbae1c53c98d2441a95c1e65799230)) by [@jirispilka](https://github.com/jirispilka) - ## [0.9.17](https://github.com/apify/apify-mcp-server/releases/tag/v0.9.17) (2026-04-08) ### 🚀 Features diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 826dae3d..a135b5a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -104,6 +104,11 @@ Restart Claude Code for the change to take effect. This token is picked up by bo | **Unit tests** | `npm run test:unit` | Individual modules in isolation — no credentials needed | | **Integration tests** | `npm run test:integration` | Full server over all transports against real Apify API (requires `APIFY_TOKEN` + `npm run build`) | | **mcpc probing** | `mcpc @stdio tools-call ...` | Interactive end-to-end verification during development | +| **LLM evals** | CI only — apply `validated` label | Runs `evals/run_evaluation.ts` against multiple models via OpenRouter; requires `PHOENIX_*` and `OPENROUTER_*` secrets | + +To trigger the eval workflow on a PR, apply the **`validated`** label. +The workflow then runs automatically and posts results to Phoenix. +It also runs automatically on every merge to the `master` branch. ### Live probing with mcpc diff --git a/evals/config.ts b/evals/config.ts index 18414240..2b217480 100644 --- a/evals/config.ts +++ b/evals/config.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'; import log from '@apify/log'; // Re-export shared config -export { OPENROUTER_CONFIG, sanitizeEnvValue, validateEnvVars, getRequiredEnvVars } from './shared/config.js'; +export { OPENROUTER_CONFIG, sanitizeEnvValue, sanitizeProcessEnv, validateEnvVars, getRequiredEnvVars } from './shared/config.js'; // Read the version from test-cases.json function getTestCasesVersion(): string { diff --git a/evals/create_dataset.ts b/evals/create_dataset.ts index 58d6bbcf..19914dee 100644 --- a/evals/create_dataset.ts +++ b/evals/create_dataset.ts @@ -14,7 +14,7 @@ import { hideBin } from 'yargs/helpers'; import log from '@apify/log'; -import { sanitizeEnvValue, validatePhoenixEnvVars } from './config.js'; +import { sanitizeEnvValue, sanitizeProcessEnv, validatePhoenixEnvVars } from './config.js'; import { loadTestCases, filterByCategory, filterById, type TestCase } from './evaluation_utils.js'; // Set log level to debug @@ -32,6 +32,7 @@ type CliArgs = { // Load environment variables from .env file if present dotenv.config({ path: '.env' }); +sanitizeProcessEnv(); // Parse command line arguments using yargs const argv = yargs(hideBin(process.argv)) diff --git a/evals/run_evaluation.ts b/evals/run_evaluation.ts index 28c3cc26..68ca87bc 100644 --- a/evals/run_evaluation.ts +++ b/evals/run_evaluation.ts @@ -30,6 +30,7 @@ import { PHOENIX_MAX_RETRIES, type EvaluatorName, sanitizeEnvValue, + sanitizeProcessEnv, validatePhoenixEnvVars } from './config.js'; @@ -58,6 +59,7 @@ const RUN_LLM_EVALUATOR = true; const RUN_TOOLS_EXACT_MATCH_EVALUATOR = true; dotenv.config({ path: '.env' }); +sanitizeProcessEnv(); // Parse command line arguments using yargs const argv = yargs(hideBin(process.argv)) diff --git a/evals/shared/config.ts b/evals/shared/config.ts index f10b573c..f74ed0dc 100644 --- a/evals/shared/config.ts +++ b/evals/shared/config.ts @@ -23,12 +23,60 @@ export function getRequiredEnvVars(): Record { } /** - * Removes newlines, trims whitespace, and strips surrounding quotes from env values. - * CI secrets often include trailing newlines or quotes that break HTTP headers and URLs. + * Strips control characters, trims whitespace, and removes surrounding double quotes. + * CI secrets often contain trailing newlines or invisible control chars that break HTTP headers. */ export function sanitizeEnvValue(value?: string): string | undefined { if (value == null) return value; - return value.replace(/[\r\n]/g, '').trim().replace(/^"|"$/g, ''); + // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').trim().replace(/^"|"$/g, ''); +} + +/** + * Env vars used in HTTP headers (API keys, tokens, URLs). + * + * Why in-place? The phoenix-otel OTel exporter reads PHOENIX_API_KEY directly + * from process.env (inside getEnvApiKey()) and passes it to node:http, which + * throws ERR_INVALID_CHAR on any control characters. We can't intercept that + * read, so we sanitize process.env itself before any library loads. + */ +const ENV_KEYS_TO_SANITIZE = [ + 'OPENROUTER_API_KEY', + 'OPENROUTER_BASE_URL', + 'PHOENIX_API_KEY', + 'PHOENIX_BASE_URL', +]; + +/** + * Redact a value for safe logging: shows first 4 and last 4 chars, masks the rest. + * Fully masks short values (≤ 8 chars) to prevent reconstruction from the log line. + * Returns '(empty)' for empty strings, '(unset)' for undefined/null. + */ +function redact(value?: string | null): string { + if (value == null) return '(unset)'; + if (value.length === 0) return '(empty)'; + if (value.length <= 6) return `*** (${value.length} chars)`; + return `${value.slice(0, 3)}***${value.slice(-3)} (${value.length} chars)`; +} + +/** + * Sanitize env vars in-place on process.env and log redacted values for CI debugging. + * Must be called before any library reads these values. + */ +export function sanitizeProcessEnv(): void { + for (const key of ENV_KEYS_TO_SANITIZE) { + const raw = process.env[key]; + if (raw != null) { + const sanitized = sanitizeEnvValue(raw)!; + const changed = raw !== sanitized; + process.env[key] = sanitized; + // eslint-disable-next-line no-console + console.log(`env ${key}: ${redact(sanitized)}${changed ? ' (sanitized)' : ''}`); + } else { + // eslint-disable-next-line no-console + console.log(`env ${key}: ${redact(raw)}`); + } + } } /** diff --git a/evals/workflows/config.ts b/evals/workflows/config.ts index b6b40572..85aca3da 100644 --- a/evals/workflows/config.ts +++ b/evals/workflows/config.ts @@ -6,7 +6,7 @@ */ // Re-export shared config for convenience -export { OPENROUTER_CONFIG, sanitizeEnvValue, validateEnvVars } from '../shared/config.js'; +export { OPENROUTER_CONFIG, sanitizeEnvValue, sanitizeProcessEnv, validateEnvVars } from '../shared/config.js'; /** * Default model configuration for agent and judge diff --git a/manifest.json b/manifest.json index b2a1a578..ab28a4f3 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "apify-mcp-server", "display_name": "Apify", - "version": "0.9.18", + "version": "0.9.19", "description": "Extract data from any website with thousands of scrapers, crawlers, and automations on Apify Store.", "long_description": "Apify is the world's largest marketplace of tools for web scraping, crawling, data extraction, and web automation. Get structured data from social media, e-commerce, search engines, maps, travel sites, or any other website.", "keywords": [ diff --git a/package-lock.json b/package-lock.json index a745d2c9..74cffa1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apify/actors-mcp-server", - "version": "0.9.18", + "version": "0.9.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apify/actors-mcp-server", - "version": "0.9.18", + "version": "0.9.19", "license": "MIT", "dependencies": { "@apify/datastructures": "^2.0.3", diff --git a/package.json b/package.json index 212d7e75..5208901d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apify/actors-mcp-server", - "version": "0.9.18", + "version": "0.9.19", "type": "module", "description": "Apify MCP Server", "mcpName": "com.apify/apify-mcp-server", diff --git a/server.json b/server.json index db5dcee2..b1226468 100644 --- a/server.json +++ b/server.json @@ -6,7 +6,7 @@ "url": "https://github.com/apify/apify-mcp-server", "source": "github" }, - "version": "0.9.18", + "version": "0.9.19", "remotes": [ { "type": "streamable-http", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2537bdae..7d469d35 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1258,6 +1258,9 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); + // Set final statusMessage before transitioning to terminal state, + // because storeTaskResult does not accept a statusMessage parameter. + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: completed`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'completed', result, mcpSessionId); log.debug('Task completed successfully', { taskId, toolName: tool.name, mcpSessionId }); @@ -1267,6 +1270,7 @@ export class ActorsMcpServer { const httpStatus = getHttpStatusCode(error); if (httpStatus === HTTP_PAYMENT_REQUIRED) { logHttpError(error, 'Payment required while calling tool (task mode)', { toolName: tool.name }); + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: payment required`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'completed', buildPaymentRequiredResponse(error), mcpSessionId); finishTaskTracking(TOOL_STATUS.SOFT_FAIL, { failure_category: FAILURE_CATEGORY.INVALID_INPUT, @@ -1312,6 +1316,7 @@ export class ActorsMcpServer { taskId, mcpSessionId, }); + await this.taskStore.updateTaskStatus(taskId, 'working', `${getToolFullName(tool)}: failed`, mcpSessionId); await this.taskStore.storeTaskResult(taskId, 'failed', { content: [{ type: 'text' as const, diff --git a/tests/unit/evals.config.test.ts b/tests/unit/evals.config.test.ts index 91f415bd..39bf3454 100644 --- a/tests/unit/evals.config.test.ts +++ b/tests/unit/evals.config.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { sanitizeEnvValue } from '../../evals/shared/config.js'; +import { sanitizeEnvValue, sanitizeProcessEnv } from '../../evals/shared/config.js'; describe('sanitizeEnvValue', () => { it('returns undefined for undefined', () => { @@ -47,8 +47,37 @@ describe('sanitizeEnvValue', () => { expect(sanitizeEnvValue('')).toBe(''); }); + it('strips control characters', () => { + expect(sanitizeEnvValue('sk-abc\x00123')).toBe('sk-abc123'); // null byte + expect(sanitizeEnvValue('sk-abc\x01123')).toBe('sk-abc123'); // SOH + expect(sanitizeEnvValue('sk-abc\x0b123')).toBe('sk-abc123'); // vertical tab + expect(sanitizeEnvValue('sk-abc\x0c123')).toBe('sk-abc123'); // form feed + expect(sanitizeEnvValue('sk-abc\x1f123')).toBe('sk-abc123'); // unit separator + expect(sanitizeEnvValue('sk-abc\x7f123')).toBe('sk-abc123'); // DEL + }); + it('is idempotent', () => { const value = ' "sk-abc123"\r\n'; expect(sanitizeEnvValue(sanitizeEnvValue(value))).toBe(sanitizeEnvValue(value)); }); }); + +describe('sanitizeProcessEnv', () => { + afterEach(() => { + delete process.env.PHOENIX_API_KEY; + delete process.env.OPENROUTER_API_KEY; + }); + + it('sanitizes env vars in-place', () => { + process.env.PHOENIX_API_KEY = 'key-with-newline\n'; + process.env.OPENROUTER_API_KEY = ' "quoted-key"\r\n'; + sanitizeProcessEnv(); + expect(process.env.PHOENIX_API_KEY).toBe('key-with-newline'); + expect(process.env.OPENROUTER_API_KEY).toBe('quoted-key'); + }); + + it('leaves unset vars untouched', () => { + sanitizeProcessEnv(); + expect(process.env.PHOENIX_API_KEY).toBeUndefined(); + }); +}); diff --git a/tests/unit/task.statusMessage.test.ts b/tests/unit/task.statusMessage.test.ts new file mode 100644 index 00000000..e63ceca0 --- /dev/null +++ b/tests/unit/task.statusMessage.test.ts @@ -0,0 +1,55 @@ +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js'; +import { describe, expect, it } from 'vitest'; + +/** + * Regression tests for #638: tasks/list shows stale statusMessage for completed tasks. + * + * storeTaskResult() does not accept a statusMessage parameter, so we must call + * updateTaskStatus() with the final message before calling storeTaskResult(). + * These tests verify that statusMessage survives the storeTaskResult() transition. + */ +describe('Task statusMessage after terminal transition', () => { + const REQUEST_ID = 'req-1'; + const REQUEST = { method: 'tools/call', params: { name: 'test-tool' } }; + + async function createWorkingTask(store: InMemoryTaskStore) { + const task = await store.createTask({ ttl: 60_000 }, REQUEST_ID, REQUEST); + return task.taskId; + } + + it('should retain final statusMessage after storeTaskResult completes the task', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + // Intermediate progress message (as set by ProgressTracker during execution) + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: Starting the crawler.'); + + // Final message set just before storeTaskResult (the fix) + await store.updateTaskStatus(taskId, 'working', 'apify/rag-web-browser: completed'); + + await store.storeTaskResult(taskId, 'completed', { + content: [{ type: 'text', text: 'result data' }], + }); + + const task = await store.getTask(taskId); + expect(task).not.toBeNull(); + expect(task!.status).toBe('completed'); + expect(task!.statusMessage).toBe('apify/rag-web-browser: completed'); + }); + + it('should retain final statusMessage after storeTaskResult marks task as failed', async () => { + const store = new InMemoryTaskStore(); + const taskId = await createWorkingTask(store); + + await store.updateTaskStatus(taskId, 'working', 'my-tool: failed'); + + await store.storeTaskResult(taskId, 'failed', { + content: [{ type: 'text', text: 'error' }], + isError: true, + }); + + const task = await store.getTask(taskId); + expect(task!.status).toBe('failed'); + expect(task!.statusMessage).toBe('my-tool: failed'); + }); +});