refactor!: Separate legacy MCP wiring from ActorsMcpServer - #1155
Merged
Conversation
jirispilka
marked this pull request as ready for review
July 24, 2026 20:44
This was referenced Jul 26, 2026
MQ37
approved these changes
Jul 27, 2026
MQ37
left a comment
Contributor
There was a problem hiding this comment.
Approving. Verified the extraction line-by-line against master — logic, error codes, notification ordering and telemetry are preserved; build, typecheck, lint and the unit suite (1177 passed / 1 skipped) are green.
One awareness note, no changes requested: because ActorsMcpServer implements LegacyMcpServerHost, five previously-private members are now public API in the shipped .d.ts:
export declare class ActorsMcpServer implements LegacyMcpServerHost {
get clientContext(): McpClientContext | undefined; // was private
resolveApifyToken(...); // was private
resolveApifyClient(...); // was private
promptService; // was internal
resourceService; // was internal
}Worth being aware of, since the breaking-change note only lists the removals (.server, .taskStore). I looked at the workarounds and do not want any of them here — the object-literal host factory hurts readability more than the wider surface does. Leaving it as-is deliberately.
jirispilka
force-pushed
the
claude/issue-1147-implementation-q4goo4
branch
from
July 27, 2026 13:50
ecddef8 to
1d44faf
Compare
Extract all v1 MCP SDK wiring out of ActorsMcpServer into a new package-private LegacyMcpServer (src/mcp/legacy_server.ts). ActorsMcpServer becomes the shared-Apify facade that constructs and delegates to the adapter; shared tool execution now takes plain values instead of the server object or the v1 RequestHandlerExtra/McpError. This fixes the dependency direction PR #1143 exposed, so a later stateless adapter can reuse one Apify core. Behavior is unchanged: tool names, results, error codes/messages/data, notifications, Tasks, telemetry, and timing are byte-for-byte identical, verified by a live mcpc diff against the pre-change server plus the full unit suite (1177 passed + 1 skipped). close() teardown is the deliberate reverse-of-connect order (server close, then tool-map clear). BREAKING: the raw v1 .server and .taskStore are no longer public on ActorsMcpServer; they move to the private adapter. Runtime-safe for the hosted server, which reads neither, but external TypeScript consumers touching them break at compile time. Closes #1147 Part of #1128 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zHfSwpr57aUaxYjxAgsof
jirispilka
force-pushed
the
claude/issue-1147-implementation-q4goo4
branch
from
July 28, 2026 11:33
1d44faf to
b502639
Compare
jirispilka
added a commit
that referenced
this pull request
Jul 28, 2026
…1163) ## Why Closing the server tried to modify read-only built-in tools and crashed before removing them. `close()` set each tool's compiled AJV validator to `null`, then cleared the tool map. Default tool entries are frozen module-level singletons, so assigning to `ajvValidate` throws in ESM strict mode: ```text TypeError: Cannot assign to read only property 'ajvValidate' ``` The exception stopped execution before `this.tools.clear()`. As a result, `ActorsMcpServer.close()` rejected and left the tool map populated. This bug already existed. It was found while building the 2026-07-28 stateless adapter. ## What changed The validator-nulling loop is deleted; `close()` now just clears the tool map. Review (thanks @MQ37) showed the loop did no useful work in any branch: - Frozen entries (all default tools) must be skipped — that write is the crash. - Payment-decorated clones share the frozen original's validator (`cloneToolEntry` restores it by reference), so nulling the clone frees nothing. - Per-Actor and proxied validators are already detached from the shared AJV instance at compile time (`fixedAjvCompile` does `removeSchema` + scope scrub), so `tools.clear()` alone makes them collectible. The regression test covers the crash: `close()` resolves and the tool map is empty afterwards, with a frozen built-in tool registered. ## Stacking This PR is stacked on #1155 rather than `master`. Two further PRs stack on this one (#1164, #1165); both are rebased on top of this change. ## Verification The regression test was written first and failed against the unmodified source with the `TypeError` above. It passes after the fix. The following checks pass on this commit: - `pnpm run type-check` - `pnpm run lint` - `pnpm run test:unit` - `pnpm run check:agents` - `pnpm run format:check` Unit test result: 84 files, 1178 passed, 1 skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
jirispilka
added a commit
that referenced
this pull request
Jul 28, 2026
## Why Preparation for #1140. A 2026-07-28 stateless request has no handshake, so it must resolve its own mode, client identity and tool set per request. Today `composeToolsForClient`, `isReportProblemServable` and `getServerInstructions` read `this._serverMode`/`this._clientContext` directly, the fetched tool sources are dropped at the initialize flush, and widget resolution is neither mode-parameterized nor reusable. None of that can serve a per-request view. This lands the restructuring on its own so the feature PR contains only new behavior. ## What changed Those three methods now take an explicit `{ serverMode, clientContext }` view. Every existing caller passes the view it already read, so the sessionful path is unchanged. Fetched `{ input, actorTools }` sources are retained alongside the pending-flush queue instead of replacing it — keeping them separate matters, because composing all retained sources at the flush would recompose a source already composed against constructor recovery data against the newer initialize client. Widget resolution splits into a mode-parameterized read and the instance-level writer. **One deliberate behavior change, and it is not behavior-preserving:** widget resolution is now memoized per facade. Previously `connect()` and `applyInitialize()` each re-scanned disk, so an explicitly-apps facade scanned twice and logged twice; now it scans once. A widget file appearing after a successful scan is no longer picked up. A *failed* scan is not memoized, so it still retries — that is tested. `toolSources` and the extracted `resolveToolEntry` have **no reader until the stateless adapter lands**. They are written and exported here so the feature PR stays additive. ## Notes for reviewers (human-written) _Left for the author._ Stacked on #1163, which is stacked on #1155. The bulk of the `server.ts` diff is signature and call-site churn. ## Proof it works `tests/unit/mcp.server.capability_gating.test.ts` and `tests/unit/mcp.server.report_problem_gating.test.ts` are **byte-identical to the base commit** — no assertion was touched. That is the behavior-preservation proof for tool composition and gating. It does not cover widget resolution, which is why the memoization change is called out above rather than claimed as a no-op. `isReportProblemServable`'s new `view.clientContext != null` check is exactly what the previous `clientKnown` getter computed (`return this.clientContext != null`). All five checks pass on this commit alone: 84 test files, 1178 passed + 1 skipped — unchanged from the fix commit beneath it, as behavior preservation implies. --- _Generated by [Claude Code](https://claude.ai/code/session_01DeTjFmDV3RK6ALz9MKZdPq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
jirispilka
added a commit
that referenced
this pull request
Jul 28, 2026
## Why Closes #1140. Part of #1128. The server speaks only the 2025-era stateful protocol. A 2026-07-28 client carries protocol version, client info and capabilities in a per-request `_meta` envelope and never handshakes, so it cannot talk to us at all. ## What changed - v2 SDK packages - New root export `createStatelessServer(actorsMcpServer)`: builds one v2 `Server` per request (the pattern `createMcpHandler`'s per-request factory is designed for) and serves `tools/*`, `resources/*` and `prompts/*` from a read-only per-request snapshot of the shared facade - Sibling of the private legacy adapter; each reads shared state only through its own narrow host interface. - `dev_server.ts` serves both protocol revisions on one endpoint via the SDK's `isLegacyRequest`; the stateful path is untouched. Adds DNS-rebinding protection (Host/Origin validation) and framing-before-auth ordering per SEP-2243 Out of scope: Tasks on 2026-07-28 and `subscriptions/listen` (follow-ups tracked in one issue). ## Notes for reviewers ("human"-written) Tested using MCP Inspector (v2/main branch) — all tools, widgets, and resources work. Claude below: Stacked on #1164 → #1163 → #1155. The only PR in the stack touching what `apify-mcp-server-internal` consumes. Deliberate behaviors worth knowing: - **`subscriptions/listen` is not refused** — the SDK answers it upstream of our handlers, opening a stream that can never emit. The dev server closes it per request; refusing it outright is follow-up work. - **Stateless instructions omit `report-problem`** — the SDK answers `server/discover` from constructor-time instructions, which cannot reflect a per-request mode. The tool itself is still gated per request. - **An anonymous stateless request is served `report-problem` by policy** — `client-info` is optional on the 2026-07-28 envelope, and the blocklist only applies to declared client names. - **Telemetry reports an empty `mcp_session_id`** on this path; what a stateless "session" means for analytics is internal's call. - **`upsertTools()`/`removeToolsByName()` bypass the retained sources**, so they change the stateful tool list only (documented in code; no callers today). - **`close()` now resolves instead of throwing** (from #1163) — any hosted teardown workaround built around a throwing `close()` is dead code. ## Proof it works - Unit suite: 89 files, 1243 passed + 1 skipped (59 new tests). `type-check`, `lint`, `format`, `check:agents` clean. - Stateful/stateless parity asserted per tool category (INTERNAL, ACTOR, ACTOR_MCP) for `tools/list` and `tools/call`, plus prompts, resources, telemetry fields, and v2 error projection. Concurrent requests with different identities resolve mode and gating independently. `tasks/*` answers method-not-found. - Official conformance suite (`@modelcontextprotocol/conformance@0.2.0-alpha.9`, `--spec-version 2026-07-28`) run against the dev server; its two real findings (auth-before-framing, missing DNS-rebinding check) are fixed in this PR. Wiring it into CI with an expected-failures baseline is #1132. - Live `mcpc` probe against the legacy stdio server confirmed no v1 regression (initialize instructions, Actor round trip, `resources/read`, error paths, prompts, logging, Tasks, apps-mode widgets). --- _Generated by [Claude Code](https://claude.ai/code/session_01DeTjFmDV3RK6ALz9MKZdPq)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: MQ37 <themq37@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Closes #1147. Part of #1128.
ActorsMcpServercombined two concerns: shared Apify behavior (tools,actorStore, server-mode, prompts/resources, widgets, payments, telemetry) and sessionful v1 SDK wiring (the SDKServer, request handlers, initialize, Tasks, logging, notifications, errors, transport lifecycle). PR #1143 showed that adding a second (stateless) serving unit forced the dependency direction backwards — shared tool execution reached up into the class (apifyMcpServer.server,.tools,extra,McpError). This extraction fixes the direction so #1140 can expose both protocol eras throughdev_server.tswhile reusing one Apify core.Breaking: the raw v1
.serverand.taskStoreare no longer public onActorsMcpServer— they move to the private adapter. Runtime-safe forapify-mcp-server-internal(it reads neither; verified against itsnew ActorsMcpServer(...)call sites, which use only.options.initializeRequestData,.loadToolsByName(),.loadToolsFromUrl(),.listAllToolNames(),.connect()). External TypeScript consumers touching those two members break at compile time — hencerefactor!.What changed
All v1 SDK wiring moved verbatim into a new package-private
LegacyMcpServer(src/mcp/legacy_server.ts).ActorsMcpServerkeeps the shared Apify behavior, constructs exactly one adapter, and delegatesconnect()/close()to it via a narrowLegacyMcpServerHostinterface — the adapter never imports the concrete facade, and shared modules never import the adapter. Shared tool execution (tool_call_engine,tool_dispatch,tool_call_telemetry,task_execution) now takes plain values;InternalToolArgsdropsextra/apifyMcpServer/mcpServer. No v2 SDK dependency and no stateless routing were added (that is #1140).Behavior is preserved byte-for-byte: tool names, results, error codes/messages/data, notifications, Tasks, telemetry, and timing are unchanged.
close()teardown is the deliberate reverse-of-connect order (close the transport, then clear the tool map).Notes for reviewers (human-written)
The huge diff is because of server.ts -> legacy_server.ts
I checked it (also with Claude), all the changes seems to be ok.
Proof it works
A live
mcpcprobe built both this branch and the pre-changemasterserver, then diffed their MCP output byte-for-byte:initialize,tools/list(all default-mode tools),prompts/list,resources/list+read,tools/callhappy paths, a live Actor round-trip (call-actor→get-dataset-items→get-key-value-store-record→resources/read), every error path (invalid tool name, invalid arguments, resource 404, unknown prompt, tool-levelSOFT_FAIL),logging/setLevel, and the task-mode pre-flight path were all identical (only per-run IDs/timestamps differed). The unit suite is unchanged at 1177 passed + 1 skipped, andtype-check/lint/check:agents/format:checkare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_017zHfSwpr57aUaxYjxAgsof
Generated by Claude Code