Skip to content

fix(core): make instanceof on SDK error classes work across bundled copies#2384

Draft
felixweinberger wants to merge 3 commits into
mainfrom
fweinberger/cross-bundle-error-instanceof
Draft

fix(core): make instanceof on SDK error classes work across bundled copies#2384
felixweinberger wants to merge 3 commits into
mainfrom
fweinberger/cross-bundle-error-instanceof

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

@modelcontextprotocol/client and @modelcontextprotocol/server each bundle their own copy of the runtime internals, so an error constructed by one package fails prototype-identity instanceof against the same class re-exported by the other — exactly the check a dual-role process (gateway, host, in-process test) writes.

Motivation and Context

Hit independently by four different migrated projects: cross-package instanceof OAuthError / ProtocolError / SdkError checks silently stop matching when both packages are installed, and the failure is runtime-only. This is the single most-reported blocking issue from real v1→v2 migrations.

ProtocolError (and its typed subclasses), SdkError/SdkHttpError, and OAuthError now stamp instances with the brand strings of their class chain under a registry symbol (Symbol.for, shared across bundles and realms) and resolve instanceof via Symbol.hasInstance against the brand set, with ordinary prototype instanceof preserved as the fallback:

  • Cross-bundle: clientConstructedError instanceof ServerReexportedClass → matches by brand.
  • Subclass → base across bundles works (SdkHttpError instance satisfies instanceof SdkError).
  • Hierarchies stay disjoint; brands are per-class and only consulted when the class owns one.
  • User-defined subclasses that don't declare a brand keep plain prototype semantics — a foreign base-class instance never satisfies instanceof UserSubclass.
  • The brand is symbol-keyed and non-enumerable: no JSON.stringify / spread / Object.keys leakage.

This does not change the packaging itself — whether to converge on a single shared runtime core remains a separate decision; this removes the user-visible breakage in the meantime.

How Has This Been Tested?

New crossBundleBrand.test.ts suite simulates the dual-bundle situation faithfully (vi.resetModules() + dynamic import → genuinely distinct class objects sharing only the symbol registry): cross-copy positives in both directions, subclass→base, fromError reconstruction from a foreign copy, hierarchy disjointness, user-subclass safety, serialization non-leakage, and primitive/null tolerance. Full suites green: core-internal 1301, client 698, server 350. Typecheck and lint clean.

Breaking Changes

None. Same-bundle behavior is unchanged (prototype fallback); the change strictly widens which checks succeed.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

JSDoc on ResourceNotFoundError / MissingRequiredClientCapabilityError previously told consumers not to rely on instanceof across bundles — updated accordingly, plus a one-line note in the migration guide's Errors section. SseError and UnauthorizedError are client-package-only (single bundle) and deliberately not branded.

@felixweinberger felixweinberger requested a review from a team as a code owner June 29, 2026 13:08
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 92b1400

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2384

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2384

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2384

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2384

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2384

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2384

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2384

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2384

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2384

commit: 92b1400

Comment on lines +436 to +438
These classes (and `OAuthError`) brand-match under `instanceof`, so checks work across
separately bundled copies of the SDK — e.g. a process using both
`@modelcontextprotocol/client` and `@modelcontextprotocol/server`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The 'Typed ProtocolError subclasses' section later in this same file still says ProtocolError.fromError "works across bundle boundaries where instanceof doesn't" — but this PR makes instanceof brand-matched and work across bundled copies, as the new paragraph added in this Errors section states. The trailing "where instanceof doesn't" clause should be removed or rewritten (e.g. "…so it also works without relying on class identity").

Extended reasoning...

What the issue is

This PR adds a paragraph in the Errors section of docs/migration/upgrade-to-v2.md (lines 436–438): "These classes (and OAuthError) brand-match under instanceof, so checks work across separately bundled copies of the SDK…". However, about 80 lines later in the same Errors section, under Typed ProtocolError subclasses (line ~517), the unchanged text still reads:

ProtocolError.fromError(code, message, data) reconstructs the typed subclass from code + data alone, so it works across bundle boundaries where instanceof doesn't.

Why this is now contradictory

The whole point of this PR is that instanceof on ProtocolError and its typed subclasses (ResourceNotFoundError, MissingRequiredClientCapabilityError, etc.) now does work across separately bundled copies: instances are stamped with brand strings under a registry symbol (Symbol.for('mcp.sdk.errorBrands')), and Symbol.hasInstance resolves instanceof against the brand set. The new crossBundleBrand.test.ts suite even covers exactly this case — ProtocolError.fromError from a foreign module copy producing an instance that satisfies instanceof ResourceNotFoundError in the local copy.

Step-by-step proof of the inconsistency

  1. A reader migrating to v2 reads the new paragraph at lines 436–438: brand-matched instanceof works across bundles.
  2. They continue down the same Errors section to "Typed ProtocolError subclasses" and read line 517: fromError is recommended because instanceof doesn't work across bundles.
  3. The two statements directly contradict each other within ~80 lines of the same section, in the same revision of the file. Post-PR behavior matches statement (1), not (2): in the new test, protocolErrors.ProtocolError.fromError(InvalidParams, 'gone', { uri: 'file:///missing' }) from a re-imported (foreign) copy returns an instance for which reconstructed instanceof ResourceNotFoundError is true against the original copy's class.

Why the existing changes don't cover it

The PR did sweep the analogous claims in the JSDoc of ResourceNotFoundError and MissingRequiredClientCapabilityError in packages/core-internal/src/types/errors.ts (which previously said not to rely on instanceof across bundles) and added the new Errors-section paragraph — but missed this one sentence. A search of the docs confirms it is the only surviving instance of the old "instanceof doesn't work across bundles" claim.

Impact

Documentation-only. A migrating user could be steered toward fromError-based reconstruction or code/data switching when a plain instanceof check is now fully supported, and the contradiction undermines confidence in the new guarantee the PR is advertising.

Fix

Drop or rewrite the trailing clause on line 517, e.g.:

ProtocolError.fromError(code, message, data) reconstructs the typed subclass from code + data alone, so it also works without relying on class identity at all.

Comment on lines +113 to +118
it('does not leak the brand through enumeration or serialization', () => {
const err = new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'nope', { status: 403 });
expect(Object.keys(err)).not.toContain(expect.stringContaining('mcp'));
const json = JSON.stringify({ ...err });
expect(json).not.toContain('mcp.Sdk');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The assertion expect(Object.keys(err)).not.toContain(expect.stringContaining('mcp')) is vacuous: toContain compares array elements with strict equality and never evaluates asymmetric matchers, so this negated check passes unconditionally and would not catch an enumerable brand key leaking. Consider expect(Object.keys(err).some(k => k.includes('mcp'))).toBe(false) or .not.toEqual(expect.arrayContaining([expect.stringContaining('mcp')])) so the test actually exercises the non-enumerability claim.

Extended reasoning...

What the bug is. In the 'does not leak the brand through enumeration or serialization' test, the line expect(Object.keys(err)).not.toContain(expect.stringContaining('mcp')) does not assert what it appears to. Vitest's toContain (matching Jest's semantics) checks array membership using strict / SameValueZero equality on elements (and substring matching only when both actual and expected are strings); it does not evaluate asymmetric matchers like expect.stringContaining. Asymmetric matchers only participate in the deep-equality matchers (toEqual, toContainEqual, expect.arrayContaining).

Why it always passes. Object.keys(err) is an array of plain strings ('code', 'data', 'name', …). None of those strings can ever be strictly equal to the AsymmetricMatcher object produced by expect.stringContaining('mcp'), so toContain is always false, and the negated form .not.toContain(...) is therefore always true — regardless of whether a brand key leaks through enumeration.

Step-by-step proof. Suppose the implementation regressed and stamped the brand under an enumerable string key, e.g. this.mcpBrands = brands instead of the symbol-keyed Object.defineProperty. Then Object.keys(err) would be ['code', 'data', 'name', 'mcpBrands']. The assertion evaluates ['code','data','name','mcpBrands'].includes(AsymmetricMatcher{sample:'mcp'}), which is false because no element is strictly equal to the matcher object. .not.toContain then passes — the test stays green even though the exact regression it is meant to catch has occurred. Conversely, in the current correct implementation it also passes, for the same reason: the assertion's outcome is independent of the code under test.

Why this is only a nit. The production behavior is correct: stampErrorBrands defines the brand under Symbol.for('mcp.sdk.errorBrands') with enumerable: false, and symbol-keyed properties never appear in Object.keys anyway. The very next assertion in the same test — JSON.stringify({ ...err }) not containing 'mcp.Sdk' — is a real, non-vacuous check that provides partial coverage of the serialization-non-leak claim. So this is purely a test-quality issue: an assertion that doesn't exercise the property its test name promises, giving false confidence rather than an incorrect runtime.

How to fix. Replace line 115 with one of:

expect(Object.keys(err).some(k => k.includes('mcp'))).toBe(false);
// or
expect(Object.keys(err)).not.toEqual(expect.arrayContaining([expect.stringContaining('mcp')]));

Either form actually evaluates the substring condition against each key, so it would fail if an enumerable mcp* key ever leaked onto instances. It may also be worth adding expect(Object.getOwnPropertySymbols(err).length) -based or propertyIsEnumerable checks if you want the test to pin down the symbol-keyed, non-enumerable storage explicitly.

Comment on lines 182 to +187
* have to declare for the request to be served. On HTTP, the response status
* is `400 Bad Request`.
*
* Recognize this error by its code and `data.requiredCapabilities` rather than
* by class identity (`instanceof` does not work across separately bundled
* copies of the SDK).
* Recognize this error by its code and `data.requiredCapabilities`, or by
* `instanceof` — checks are brand-matched and work across separately bundled
* copies of the SDK.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Several test-file header comments still document the pre-PR assumption that instanceof does not work across bundled copies of the SDK, contradicting the brand-matched guarantee this PR adds (and the JSDoc it rewrites in this file): packages/core-internal/test/types/missingClientCapabilityError.test.ts:6, packages/core-internal/test/types/errorSurfacePins.test.ts:5-6 and :95-96, packages/core-internal/test/types/crossBundleErrorRecognition.test.ts:5-9, and test/e2e/scenarios/resources.test.ts:202. Comment-only — sweep or soften them in this PR (e.g. "works across bundles only when both copies carry the brand"); note the existing review comment about the migration guide claimed line ~517 was the only surviving instance, which these sites disprove.

Extended reasoning...

What the issue is. This PR's whole point is that instanceof on the SDK error classes is now brand-matched (Symbol.hasInstance + a Symbol.for registry symbol) and therefore works across separately bundled copies of the SDK. The PR sweeps the old "do not rely on instanceof across bundles" wording from the JSDoc on ResourceNotFoundError and MissingRequiredClientCapabilityError in packages/core-internal/src/types/errors.ts and adds a paragraph in the migration guide advertising the new guarantee. However, several sibling comments documenting the old assumption were not swept and now contradict the guarantee the same diff advertises.

Surviving sites (verified at the PR head):

  1. packages/core-internal/test/types/missingClientCapabilityError.test.ts:4-6 — header: recognition is by code + data.requiredCapabilities shape, "never by instanceof across bundles". This directly contradicts the new JSDoc on MissingRequiredClientCapabilityError that this PR rewrites to say instanceof checks "are brand-matched and work across separately bundled copies of the SDK".
  2. packages/core-internal/test/types/errorSurfacePins.test.ts:5-6"not only by enum member or instanceof (which breaks across bundled package boundaries)" — and :95-96"instanceof breaks when core is bundled into both the client and server dists". No longer categorically true for the branded classes.
  3. packages/core-internal/test/types/crossBundleErrorRecognition.test.ts:4-9"a typed error class constructed inside one bundle is NOT instanceof the 'same' class imported from another bundle … must never rely on instanceof across the package boundary". (The simulated foreign class in that test is intentionally unbranded, so the test still passes — only the prose is stale.)
  4. test/e2e/scenarios/resources.test.ts:201-203"(no instanceof across bundles)" justifying the data-parse recognizer.

Step-by-step proof of the contradiction. A maintainer reads the new JSDoc on MissingRequiredClientCapabilityError (packages/core-internal/src/types/errors.ts:182-187): "Recognize this error by its code and data.requiredCapabilities, or by instanceof — checks are brand-matched and work across separately bundled copies of the SDK." They then open the test that pins this class's behavior, missingClientCapabilityError.test.ts, and the very first paragraph says recognition is "never by instanceof across bundles". Both statements are in the tree at the same commit and cannot both be right; post-PR behavior matches the JSDoc, not the test header — the new crossBundleBrand.test.ts suite proves a foreign-copy ResourceNotFoundError satisfies instanceof against the local copy's class.

Why the existing review comment doesn't cover it. The existing claude[bot] inline comment targets only docs/migration/upgrade-to-v2.md line ~517 ("works across bundle boundaries where instanceof doesn't") and explicitly claims that line was "the only surviving instance" of the old claim. These four test-file sites disprove that claim and would not be touched by fixing the doc line, so this is an additional, distinct set of locations.

Impact. Comment-only — no runtime or test-outcome change. But the comments describe pre-PR behavior and will mislead maintainers (and anyone reading the tests as documentation of the recognition contract) about whether instanceof is reliable across bundles, and they undercut the guarantee this PR's own JSDoc and migration-guide paragraph advertise. The statements remain true only in narrower edge cases (against pre-brand SDK copies, or for plain wire-shape values that are never branded), which is exactly the nuance the comments should be softened to.

How to fix. Sweep these comments in the same PR. Either delete the stale parentheticals, or soften them to the precise post-PR contract, e.g. "instanceof works across bundles only when both copies carry the brand (this release or later); the data-parse recognizer remains the version-agnostic path and works on plain wire shapes that are never branded." That keeps the rationale for the data-parse contract the tests pin while no longer contradicting the new guarantee.

…opies

@modelcontextprotocol/client and @modelcontextprotocol/server each bundle
their own copy of the runtime internals, so an error constructed by one
package failed prototype-identity instanceof against the same class
re-exported by the other - exactly the check a dual-role process (gateway,
host, in-process test) writes.

ProtocolError and its typed subclasses, SdkError/SdkHttpError, and OAuthError
now stamp instances with the brand strings of their class chain under a
registry symbol (Symbol.for, shared across bundles) and resolve instanceof
via Symbol.hasInstance against the brand set, with ordinary prototype
instanceof as the fallback. User-defined subclasses that do not declare a
brand keep plain prototype semantics, so a foreign base-class instance never
satisfies instanceof UserSubclass.
A brand reachable through the prototype chain no longer satisfies the check,
so polluting a shared prototype with the registry symbol cannot make
arbitrary objects pass instanceof against the branded error classes. Real
instances are stamped with an own property and are unaffected.
- Move brands from declared protected static fields into static blocks:
  the field declaration reached the .d.mts and TypeScript's protected-member
  rule made the constructor types nominally incompatible across the two
  packages (typeof ProtocolError from client was unassignable to server's).
  Static blocks keep the own runtime property with no type-surface footprint.
- Harden brandedHasInstance against hostile Proxy traps and throwing
  accessors: instanceof must not throw where it previously returned false.
- Brand SseError: the migration guide's HTTP-status classification row pairs
  instanceof SdkHttpError with instanceof SseError; both are now
  cross-bundle-robust (two client copies in one process).
- Lock the cross-version contract in tests (identity, not shape) and document
  it plus the both-copies requirement in the changeset and module JSDoc.
@felixweinberger felixweinberger force-pushed the fweinberger/cross-bundle-error-instanceof branch from 0d8362a to 92b1400 Compare June 29, 2026 13:43
@felixweinberger felixweinberger marked this pull request as draft June 30, 2026 13:39
@felixweinberger

Copy link
Copy Markdown
Contributor Author

Reopened as a draft to serve as a starting point for a successor PR. Status notes for whoever picks it up:

  • The branch is complete and was green at its base (core-internal 1304 / client 701 / server 350 tests, typecheck, lint), but main has moved — it needs a rebase before building on it.
  • Review-hardened details worth keeping in any successor: the brand statics are set in static {} blocks via Object.defineProperty rather than declared fields (a declared protected static reaches the .d.mts and TypeScript's protected-member rule makes the constructor types nominally unassignable across the two packages); the check honors only own-property carriers (objects inheriting the registry key through the prototype chain are not matched); the carrier read is wrapped in try/catch so values with throwing property traps cannot make instanceof throw where it previously returned false; SseError is branded alongside the core classes because the migration guide's HTTP-classification guidance pairs it with SdkHttpError; user-defined subclasses keep plain prototype semantics.
  • The test file simulates the dual-bundle situation with vi.resetModules() + dynamic import (genuinely distinct class objects sharing only the symbol registry), and a dist-level cross-check against the built client/server bundles passed at the time (no minification anywhere in the build pipeline; server-legacy bundles a third copy where the brands also work; @modelcontextprotocol/core contains no error classes).
  • Contract notes are in the changeset: cross-bundle matching needs both copies at or after the release that introduces branding, and brands assert identity rather than field shape across versions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant