# Development ## Overview This repository (**public**) provides: - The core MCP server implementation (published as an NPM package) - The stdio entry point (CLI) - An Express HTTP server for local development and testing The hosted server (**[mcp.apify.com](https://mcp.apify.com)**) is implemented in an internal Apify repository that depends on this package. For general information about the Apify MCP Server, features, tools, and client setup, see the [README.md](./README.md). ## Project structure (high-level) ```text src/ mcp/ MCP protocol implementation tools/ MCP tool implementations resources/ Resources and widgets metadata utils/ Shared utilities web/ React UI widgets (built into dist/web) tests/ unit/ Unit tests integration/ Integration tests ``` Key entry points: - `src/index.ts` - Main library export (`ActorsMcpServer` class, plus `createStatelessServer` — the per-request registration for 2026-07-28 traffic) - `src/index_internals.ts` - Internal exports for testing / advanced usage (`./internals`, consumed by apify-mcp-server-internal) - `src/index_internals_test_kit.ts` - Internal exports for this package's own `tests/test_kit/**` only (`./internals/test-kit`) - `src/stdio.ts` - Standard input/output (CLI) entry point - `src/dev_server.ts` - Express HTTP server for local development (`pnpm start`) - `src/input.ts` - Input processing and validation ## Tool loading phases Tool loading is intentionally split into two phases in [`src/utils/tools_loader.ts`](./src/utils/tools_loader.ts): - `getActors()` — async, mode-agnostic. Fetches Actor metadata and preserves the caller's requested tool/actor selection without choosing any mode-dependent tool variants. - `getToolsForServerMode()` — sync, mode-dependent. Takes the pre-fetched sources plus a resolved `SERVER_MODE` and produces the concrete tool entries to expose to the client. - `loadToolsFromInput()` in `tools_loader.ts` — convenience wrapper running both phases back-to-back with an explicit `SERVER_MODE`. **Not to be confused with** `ActorsMcpServer.loadToolsFromInput()` (the public method), which queues sources when mode is still `'auto'` and registers them onto the server — call the server method from transport entry points, the plain function only when you already have a resolved mode. This split matters for `serverMode: 'auto'`. - Before `initialize`, the server does not yet know whether the client supports MCP Apps. - Public preload helpers such as `ActorsMcpServer.loadToolsByName()` and `loadToolsFromUrl()` therefore queue mode-agnostic sources first. - Actor tools may still be loaded immediately because they are mode-agnostic. - During `initialize`, once client capabilities are known, the server resolves the queued sources into the stateful connection's mode-dependent tool set. ### Two places sources get resolved Fetched sources are **retained, not drained**, because there are two consumers: - The stateful (2025-era) path resolves them once at `initialize`, as above, into the shared `ActorsMcpServer.tools` map that lives for the connection. - The stateless (2026-07-28) path has no `initialize`. `ActorsMcpServer.createRequestSnapshot()` re-composes **all** retained sources per request, against that request's own resolved mode and declared client identity, into a snapshot the shared map never sees. Consequence for both: a tool only reaches the stateless path if it arrives through a load path. `upsertTools()` writes the shared map directly and is not reflected back into the sources, so it changes the stateful tool list only (it documents this). `close()` is the opposite — the release point for everything the facade retains, sources included, so nothing composes after it. Rule of thumb: - If code may run before `initialize` in `auto` mode, it must stay in the mode-agnostic phase. - Only code running after mode resolution should call `getToolsForServerMode()` or otherwise choose concrete mode-dependent tool variants. ## Node.js version policy The minimum supported Node.js version is **22** (`engines.node >= 22.0.0` in `package.json`). **Why Node.js 22 (not 20):** - pnpm 11 (the pinned package manager) requires Node 22.13+, so the dev workflow needs Node 22+ regardless. - The CI test matrix runs on `[22, 24, 26]` — Node 20 is not validated pre-publish. - A matrix tarball smoke test on Node 20 at release time would close the gap, but the CI complexity isn't worth it given Sentry data shows Node 20 is a small user segment. - Setting `engines >= 22` matches what CI actually validates and what dev tooling already requires. It's the honest floor. If you ever want to lower the floor again, you'd need either an oxlint rule that flags unsupported Node builtins, or a matrix tarball smoke gate before `npm publish`. Don't lower `engines` without one of those in place. The `.nvmrc` file pins the dev-tooling Node version (currently 24) — this is intentionally higher than the published floor. ## How to contribute Refer to the [CONTRIBUTING.md](./CONTRIBUTING.md) file. ### Installation This repo uses **pnpm 11+** as the package manager. corepack (bundled with Node 16+) reads `package.json#packageManager` and pins the exact version for you — no manual install needed. ```bash corepack enable # one-off, makes pnpm available pnpm install # installs root + src/web (workspace package) in one pass ``` `devEngines.packageManager` is pinned with `onFail: "error"`, so `npm install` / `yarn install` refuse to run inside the checkout — keeps the lockfile single-source. ### Working on the MCP Apps (ChatGPT Apps) UI widgets Widget code lives in `src/web/` (a self-contained React project). Widgets are rendered based on tool output — to add data to a widget, modify the corresponding tool's return value. > **UI mode:** Widget rendering requires the server to run in UI mode. Use `?ui=true` (e.g., `/mcp?ui=true`) or set `UI_MODE=true`. See the [OpenAI Apps SDK documentation](https://developers.openai.com/apps-sdk) for background on MCP Apps and widgets. ### Production build ```bash pnpm run build ``` Builds the core TypeScript project and `src/web/` widgets, then copies widgets into `dist/web/`. Required before running integration tests or the compiled server. ### Hot-reload development ```bash pnpm run dev ``` Starts the web widgets builder in watch mode and the MCP server in standby mode on port `3001`. The dev server mirrors production auth — it does **not** read `APIFY_TOKEN` from its environment. Every request must carry the token via one of (in priority order): 1. `Authorization: Bearer ` header 2. `?token=` query parameter (handy for quick browser-based widget previews) Editing `src/web/src/widgets/*.tsx` triggers a hot-reload — the next widget render uses updated code without restarting the server. Adding new widget filenames requires reconnecting the MCP client to pick them up. - The repo's [`.mcp.json`](./.mcp.json) ships a `dev` entry wired with `Authorization: Bearer ${APIFY_TOKEN}` — `${APIFY_TOKEN}` is populated via [Claude Code setup](#configuring-apify_token-for-claude-code) below. - Get your Apify API token from [Apify Console](https://console.apify.com/settings/integrations) - Preview widgets via the local esbuild dev server at `http://localhost:3226/index.html` The MCP server listens on port `3001`. The HTTP server implementation is in `src/dev_server.ts`. The hosted production server behind [mcp.apify.com](https://mcp.apify.com) is located in the internal Apify repository. ### Configuring APIFY_TOKEN for Claude Code Create or edit `.claude/settings.local.json`: ```json { "env": { "APIFY_TOKEN": "" } } ``` Restart Claude Code for the change to take effect. This token is picked up by both Claude Code MCP servers (defined in `.mcp.json`) and mcpc. ## Testing | Layer | Command | What it covers | |---|---|---| | **Unit tests** | `pnpm run test:unit` | Individual modules in isolation — no credentials needed | | **Integration tests** | `pnpm run test:integration` | Full server over stdio, streamable HTTP and the `2026-07-28` stateless HTTP dimension against real Apify API (requires `APIFY_TOKEN` + `pnpm run build`) | | **Conformance tests** | `pnpm run test:conformance` | Official MCP conformance runner (`--suite all`) against a compiled dev server, run once per protocol era — spec versions `2026-07-28` and `2025-11-25`, in that order. The command builds first, runs both eras, and exits with the first non-zero code. `_conformance_tests.yaml`, called by `_integration_tests.yaml`, runs the same coverage in CI. Excluded scenarios and their reasons live in `scripts/conformance_expected_failures_2026_07_28.yaml` and `scripts/conformance_expected_failures_2025_11_25.yaml` (requires `APIFY_TOKEN`; set `PORT` to override port 3001) | | **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. ### Test Actors Integration tests run against two purpose-built Actors defined in [apify/mcp-server-test-actor](https://github.com/apify/mcp-server-test-actor) and referenced from [`tests/test_kit/helpers.ts`](./tests/test_kit/helpers.ts): | Actor | Constant | Purpose | |---|---|---| | `apify/normal-mode-test-actor` | `ACTOR_NORMAL_MODE` | A normal Actor that writes dataset and key-value-store output. Exercises `call-actor`, the canonical run response, and the storage tools (`get-dataset-items`, `get-dataset`, `get-key-value-store-*`). | | `apify/example-mcp-server` | `ACTOR_EXAMPLE_MCP_SERVER` | A standby MCP-server Actor. Exercises the MCP-in-MCP proxy path, where the server registers the Actor's own MCP tools as sub-tools. | ### Test structure - `tests/unit/` — unit tests for individual modules - `tests/integration/` — integration tests for MCP server functionality - `tests/integration/suite.ts` — wires the transport dimensions into one shared suite; add new cases to the matching group in `tests/test_kit/cases/*.cases.ts`, not here - Other files in this directory set up different transport dimensions (`stdio`, `2025-11-25` streamable HTTP, and `2026-07-28` stateless HTTP driven by the v2 SDK client) that all use `suite.ts` - `tests/helpers.ts` — shared test utilities - `tests/test_kit/helpers.ts` — shared test constants and assertion helpers, published behind `./test-kit` ### Test organization across repos **Why `CaseCtx` couples the two repos.** MCP contract tests (protocol, tools, prompts, resources) have one correct behavior regardless of who's hosting the server, so they live here once and internal imports them — no second copy to keep in sync. Internal keeps only what's genuinely deployment-specific: databases, rate limiting, auth, multi-node coordination. `CaseCtx` is the seam that makes sharing possible without merging the two codebases — it's a non-standard cross-repo dependency injection (each side supplies its own client/environment for the same case logic), accepted deliberately for this trade-off. This package is also used by the hosted server in `apify-mcp-server-internal`. Every integration case is a `Case` object (`{ name, isDeploymentTest, run, ... }`) defined in `tests/test_kit/cases/*.cases.ts` — published behind the package's `./test-kit` export (`vitest` optional peerDependency). This repo's own `suite.ts` runs every case via `registerCases(name, allGroupCases, ctx)`; internal imports the same case arrays via `@apify/actors-mcp-server/test-kit` and calls `registerCases(name, allCases, { ...ctx, isDeploymentTestOnly: true })` against its own live staging/prod deploy — cases with `isDeploymentTest: false` register as `it.skip` there, so nothing needs re-registering by hand. Cases that share expensive setup (one seeded Actor run, say) do it via `ctx.getFixture(fixture)` (`Fixture = { key, setup(ctx) }`) instead of a vitest `beforeAll` — `setup` runs at most once per `registerCases` call (once per transport dimension), memoized by `fixture.key`, no matter how many or few of the cases sharing it actually run (e.g. under `isDeploymentTestOnly`). This is what lets `storage.cases.ts`'s 13 cases built on one seeded run stay ordinary, individually eligible-for-`isDeploymentTest` `Case` objects instead of a separate non-flattenable group. See `tests/test_kit/register.ts` and `storage.cases.ts`'s `normalModeRunFixture`. Marking a case `isDeploymentTest: true` is a one-line edit on its existing definition — there is no second array or file to keep in sync, and internal picks up every current and future deployment-test case automatically on its next dependency bump. Flipping a case to `isDeploymentTest: true` is a per-PR judgment call, not automatic — most cases stay `isDeploymentTest: false` (this-repo-only in practice, since internal only registers the `isDeploymentTest` subset). This is a real execution decision, not a visibility flag: internal's `isDeploymentTestOnly` `registerCases` call runs as part of its release pipeline — against **staging** when a release PR opens, against **production** when it merges to `master` (real API calls, real Actor runs, billed and user-visible). Mark a case `isDeploymentTest: true` only if it earns that. **Tests in this repo** cover the package's MCP and library surface. They live in `tests/test_kit/cases/*.cases.ts` (registered through `tests/integration/suite.ts`) and `tests/unit/`: - MCP protocol — `initialize` handshake, request/response shapes for `tools/*`, `prompts/*`, `resources/*`, `tasks/*`, notification delivery, JSON-RPC error codes. - Package logic — tool loader and selectors, widget metadata shape, structured output schemas, prompt registry, built-in tools, `call-actor` `RunResponse` shape, `SkyfirePaymentProvider`, client-name capability detection, `?ui=` server-mode parsing. **Tests not in this repo** — anything that only makes sense with the hosted stack: - IAM auth gate (401, unauth user toolset filter, `?payment=skyfire` bypass), rate limiter, `RedisEventStore` replay via `Last-Event-ID`, user-aware rental Actor filter, non-MCP HTTP routes (`/`, OAuth metadata, server card). - Multi-node coordination — cross-node session continuity and cancellation, failover through the Caddy load balancer. Those live in `apify-mcp-server-internal`, together with a small **contract smoke suite** that re-asserts our package's behavior survives the hosted server's extra code (auth, rate limiter, Caddy, response handlers). We don't maintain that suite — internal does — but we should know it exists. #### When your change affects the hosted server The hosted server wraps this package with auth, rate limiter, Caddy, and response handlers. Any of that can change, drop, or delay something we produce; our tests don't see that wrapping. So if you change something the hosted server consumes — `internals.js` exports, `_meta` shape, `structuredContent`, anything that depends on `clientInfo`, `?ui=` / `?payment=` parsing, the timing of notifications — flag it in the PR. Internal's contract suite likely needs a matching test. #### Don't delete a public test thinking internal covers it The flow is one way. We're the source; internal smokes guard the package's output as it passes through the hosted server. If you cut a test here, internal has no way to catch the regression on its own. ### Live probing with mcpc `mcpc` (`@apify/mcpc`) provides a CLI feedback loop against the local server. #### Setup ```bash brew install apify/tap/mcpc # or: pnpm add -g @apify/mcpc pnpm run build mcpc connect .mcp.json:stdio @stdio mcpc @stdio tools-list # verify ``` #### Usage Arguments use `key:=value` syntax (auto-parses as JSON): ```bash mcpc @stdio tools-list mcpc @stdio tools-call search-actors keywords:="web scraper" limit:=5 mcpc --json @stdio tools-call search-actors keywords:="scraper" | jq ‘.content[0].text’ ``` **Key behaviors to verify:** - `search-actors` — test valid keywords, empty keywords - `fetch-actor-details` — test valid Actor, non-existent Actor - `call-actor` — test with valid input; check async mode - `get-dataset-items` — test field filtering with dot notation, non-existent dataset - `search-apify-docs` / `fetch-apify-docs` — test relevant and non-existent queries ### Testing with MCPJam (optional) Run [MCPJam](https://www.mcpjam.com/) with `npx @mcpjam/inspector@latest`. 1. Click **"Add new server"**, enter URL `http://localhost:3001/mcp?ui=true`, select **"No authentication"** 2. **App Builder** — select a tool, fill arguments, execute, view rendered widget 3. **Chat** — add an OpenAI/Anthropic/OpenRouter API key to chat with widget rendering inline ### Testing with ChatGPT (optional) Test widget rendering on [chatgpt.com](https://chatgpt.com) by exposing the local server via ngrok. See the [Apify ChatGPT integration docs](https://docs.apify.com/platform/integrations/chatgpt) for background. The ngrok credentials are in **1Password**. The static domain `mcp-apify.ngrok.dev` is already set up — add to `~/.config/ngrok/ngrok.yml`: ```yaml tunnels: app: addr: 3001 proto: http domain: mcp-apify.ngrok.dev ``` Then start the tunnel: ```bash ngrok start app ``` The MCP server API will be reachable at `https://mcp-apify.ngrok.dev/?ui=true`. #### Adding the server in ChatGPT 1. Go to [chatgpt.com](https://chatgpt.com) and open **Settings → Connectors** 2. Click **"Add a custom connector"** 3. Enter the URL: `https://mcp-apify.ngrok.dev/?ui=true` 4. Save and start a new chat > **Important:** After restarting ngrok, use the **Refresh** button in the connector settings to reconnect — ChatGPT does not detect the tunnel restart automatically.