Skip to content

refactor!: Separate legacy MCP wiring from ActorsMcpServer - #1155

Merged
jirispilka merged 2 commits into
masterfrom
claude/issue-1147-implementation-q4goo4
Jul 28, 2026
Merged

refactor!: Separate legacy MCP wiring from ActorsMcpServer#1155
jirispilka merged 2 commits into
masterfrom
claude/issue-1147-implementation-q4goo4

Conversation

@jirispilka

@jirispilka jirispilka commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Why

Closes #1147. Part of #1128.

ActorsMcpServer combined two concerns: shared Apify behavior (tools, actorStore, server-mode, prompts/resources, widgets, payments, telemetry) and sessionful v1 SDK wiring (the SDK Server, 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 through dev_server.ts while reusing one Apify core.

Breaking: the raw v1 .server and .taskStore are no longer public on ActorsMcpServer — they move to the private adapter. Runtime-safe for apify-mcp-server-internal (it reads neither; verified against its new ActorsMcpServer(...) call sites, which use only .options.initializeRequestData, .loadToolsByName(), .loadToolsFromUrl(), .listAllToolNames(), .connect()). External TypeScript consumers touching those two members break at compile time — hence refactor!.

What changed

All v1 SDK wiring moved verbatim into a new package-private LegacyMcpServer (src/mcp/legacy_server.ts). ActorsMcpServer keeps the shared Apify behavior, constructs exactly one adapter, and delegates connect()/close() to it via a narrow LegacyMcpServerHost interface — 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; InternalToolArgs drops extra/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 mcpc probe built both this branch and the pre-change master server, then diffed their MCP output byte-for-byte: initialize, tools/list (all default-mode tools), prompts/list, resources/list + read, tools/call happy paths, a live Actor round-trip (call-actorget-dataset-itemsget-key-value-store-recordresources/read), every error path (invalid tool name, invalid arguments, resource 404, unknown prompt, tool-level SOFT_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, and type-check/lint/check:agents/format:check are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_017zHfSwpr57aUaxYjxAgsof


Generated by Claude Code

@MQ37 MQ37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
jirispilka force-pushed the claude/issue-1147-implementation-q4goo4 branch from ecddef8 to 1d44faf Compare July 27, 2026 13:50
claude and others added 2 commits July 28, 2026 13:33
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
jirispilka force-pushed the claude/issue-1147-implementation-q4goo4 branch from 1d44faf to b502639 Compare July 28, 2026 11:33
@jirispilka
jirispilka merged commit 5ebe0d8 into master Jul 28, 2026
12 checks passed
@jirispilka
jirispilka deleted the claude/issue-1147-implementation-q4goo4 branch July 28, 2026 11:47
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-ai Issues owned by the AI team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: Separate legacy MCP wiring from ActorsMcpServer

4 participants