Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/export-protocol-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/codemod': patch
---

Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (e.g. the MCP Apps SDK). The client and server packages each bundle their own compiled copy of the class, so import it from one package consistently within a process.

The codemod now rewrites `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The SDK is organized into three main layers:
The SDK separates internal code from the public API surface:

- **`@modelcontextprotocol/core-internal`** (main entry, `packages/core-internal/src/index.ts`) — Internal barrel. Exports everything (including Zod schemas, Protocol class, stdio utils). Only consumed by sibling packages within the monorepo (`private: true`).
- **`@modelcontextprotocol/core-internal/public`** (`packages/core-internal/src/exports/public/index.ts`) — Curated public API. Exports only TypeScript types, error classes, constants, and guards. Re-exported by client and server packages.
- **`@modelcontextprotocol/core-internal/public`** (`packages/core-internal/src/exports/public/index.ts`) — Curated public API. Exports TypeScript types, error classes, constants, guards, and the `Protocol` base class (+ `mergeCapabilities`). Re-exported by client and server packages.
- **`@modelcontextprotocol/client`** and **`@modelcontextprotocol/server`** (`packages/*/src/index.ts`) — Final public surface. Package-specific exports (named explicitly) plus re-exports from `core-internal/public`.
- **`@modelcontextprotocol/core`** (`packages/core/src/index.ts`) — Public Zod-schema package and the canonical home of the schema source modules (`src/schemas.ts`, `src/auth.ts`, `src/constants.ts`). The root entry re-exports **only** the `*Schema` Zod constants (MCP spec + OAuth/OpenID) — the published home for raw runtime validation (`CallToolResultSchema.parse(...)`); runtime-neutral (`zod` is its only dependency). The `./internal` subpath re-exports the schema modules wholesale for the sibling packages: `core-internal` re-exports them at the old module paths, and the `client`/`server`/`server-legacy` bundles resolve `@modelcontextprotocol/core/internal` as a real external dependency instead of carrying their own schema copies (their public surfaces stay Zod-free).

Expand Down
21 changes: 11 additions & 10 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,8 @@
`completable(schema, cb).optional()` (see
[Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated)); shapes it
cannot invert get an `@mcp-codemod-error` marker.
- Drops `Protocol` / `mergeCapabilities` from `shared/protocol.js` imports, re-exports,
mocks, and dynamic imports — no v2 package exports them — leaving a marker with the
replacement at each site.
- Rewrites `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
client or server package root, like the module's other symbols.

## What the codemod does NOT handle

Expand Down Expand Up @@ -1444,13 +1443,15 @@
objects. A payload typed `Record<string, unknown>` no longer assigns (`TS2322`) — give
the source a JSON-compatible type or cast at the boundary.

The `Protocol` base class itself is no longer exported (it is internal engine). If you
were reaching into protocol internals — rare, mostly debugging tools —
`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every
inbound request that no registered handler matches, before capability gating. Delete
the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod
drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves an
`@mcp-codemod-error` marker at the site explaining the replacement.
The `Protocol` base class and `mergeCapabilities` moved: import them from the
`@modelcontextprotocol/client` or `@modelcontextprotocol/server` package root instead
of `shared/protocol.js`. Most code should not use `Protocol` directly — `Client` and
`Server` are the supported surfaces, and for observing unmatched inbound requests
prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`. The codemod
rewrites `Protocol` and `mergeCapabilities` imports to the package root, like the
module's other symbols. One caveat: the client and server packages each bundle their
own compiled copy of the class, so the two roots' `Protocol` exports are distinct
classes — import it from one package consistently within a process.

Check warning on line 1454 in docs/migration/upgrade-to-v2.md

View check run for this annotation

Claude / Claude Code Review

Migration guide frames Protocol as a pure import move, but the v1→v2 subclass contract changed and is documented nowhere

The paragraph frames the Protocol move as a pure import rewrite ("like the module's other symbols"), but the v1→v2 subclass contract also changed: v2's `Protocol` takes a single `ContextT` type parameter (v1 had three wire-shape parameters, so every `extends Protocol<A, B, C>` fails TS2314) and adds a required abstract `buildContext(ctx, transportInfo?)` that no v1 subclass implements (TS2515) — and `buildContext` appears nowhere in docs/. Since this PR also removes the codemod's action-required
Comment thread
felixweinberger marked this conversation as resolved.

#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)

Expand Down
14 changes: 14 additions & 0 deletions packages/client/test/client/protocolExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Pins that `Protocol` and `mergeCapabilities` are exported from the package
* root (carried by the `export *` of the core-internal public barrel).
*/
import { describe, expect, test } from 'vitest';

import { mergeCapabilities, Protocol } from '../../src/index';

describe('package root exports', () => {
test('Protocol and mergeCapabilities are exported from the client root', () => {
expect(typeof Protocol).toBe('function');
expect(typeof mergeCapabilities).toBe('function');
});
});
4 changes: 2 additions & 2 deletions packages/codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ guards that mix the two enums), add `import { z } from 'zod'` when a wrap needs
it, rewrite `vi.mock`
/ `jest.mock` / dynamic `import()` paths, invert optional completable nesting
(`completable(schema.optional(), cb)` becomes `completable(schema, cb).optional()`),
and drop `Protocol` / `mergeCapabilities` (no v2 export) with an action-required
marker naming the replacement.
and rewrite `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
client or server package root.

## `@mcp-codemod-error` markers

Expand Down
12 changes: 1 addition & 11 deletions packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,7 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
},
'@modelcontextprotocol/sdk/shared/protocol.js': {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved',
removedSymbols: {
Protocol:
'The Protocol base class is not exported by the v2 packages. To observe or handle inbound requests ' +
'that have no registered handler, use client.fallbackRequestHandler / server.fallbackRequestHandler; ' +
'build custom behavior on Client or Server instead of subclassing Protocol. ' +
'See the migration guide: Behavioral changes > Client connection & dispatch.',
mergeCapabilities:
'mergeCapabilities() is not exported by the v2 packages. Pass the complete capabilities object to the ' +
'Client/Server constructor, or merge capability objects with a plain object spread.'
}
status: 'moved'
},
Comment thread
claude[bot] marked this conversation as resolved.
'@modelcontextprotocol/sdk/shared/transport.js': {
target: 'RESOLVE_BY_CONTEXT',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,9 @@ export const importPathsTransform: Transform = {
);
}
}
// Qualified accesses to symbols with no v2 export (`ns.Protocol`) can't be fixed by
// Qualified accesses to symbols with no v2 export (`ns.GoneClass`) can't be fixed by
// moving the namespace binding — flag each accessed one. Expression positions are
// PropertyAccessExpressions; type positions (`let p: ns.Protocol`) are QualifiedNames.
// PropertyAccessExpressions; type positions (`let p: ns.GoneClass`) are QualifiedNames.
if (mapping.removedSymbols) {
const nsName = namespaceImport.getText();
const accessedRemoved = new Map<string, Node>();
Expand Down
122 changes: 88 additions & 34 deletions packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Project } from 'ts-morph';

import { IMPORT_MAP } from '../../../src/migrations/v1-to-v2/mappings/importMap';
import { importPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/importPaths';
import type { TransformContext } from '../../../src/types';

Expand All @@ -11,6 +12,13 @@ function applyTransform(code: string, context: TransformContext = { projectType:
return sourceFile.getFullText();
}

function applyWithDiagnostics(code: string, context: TransformContext = { projectType: 'server' }) {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', code);
const result = importPathsTransform.apply(sourceFile, context);
return { text: sourceFile.getFullText(), result };
}

describe('import-paths transform', () => {
it('rewrites client imports to @modelcontextprotocol/client', () => {
const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`;
Expand Down Expand Up @@ -1006,93 +1014,139 @@ describe('auth types routing (B3)', () => {
});
});

describe('symbols with no v2 export (removedSymbols)', () => {
function applyWithDiagnostics(code: string, context: TransformContext = { projectType: 'server' }) {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('test.ts', code);
const result = importPathsTransform.apply(sourceFile, context);
return { text: sourceFile.getFullText(), result };
}

it('drops Protocol from a shared/protocol.js import and flags it', () => {
describe('Protocol and mergeCapabilities route like other shared/protocol.js symbols', () => {
it('rewrites a Protocol import to the context package root', () => {
const input = `import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).not.toContain('Protocol }');
expect(text).toMatch(/import \{ Protocol \} from ['"]@modelcontextprotocol\/server['"]/);
expect(text).not.toContain('@modelcontextprotocol/sdk');
const diag = result.diagnostics.find(d => d.insertComment && d.message.includes('Protocol base class'));
expect(diag).toBeDefined();
expect(diag?.message).toContain('fallbackRequestHandler');
expect(result.diagnostics.filter(d => d.insertComment)).toEqual([]);
});

it('routes surviving siblings while dropping the removed symbol', () => {
it('keeps Protocol alongside its siblings in a mixed import', () => {
const input = `import { Protocol, type ProtocolOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toContain('Protocol');
expect(text).toContain('ProtocolOptions');
expect(text).toContain('@modelcontextprotocol/server');
expect(text).not.toMatch(/\bProtocol\b(?!Options)/);
expect(result.diagnostics.some(d => d.message.includes('Protocol base class'))).toBe(true);
expect(result.diagnostics.filter(d => d.insertComment)).toEqual([]);
});

it('flags mergeCapabilities with spread guidance', () => {
it('rewrites a mergeCapabilities import with no spread guidance', () => {
const input = `import { mergeCapabilities } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toMatch(/import \{ mergeCapabilities \} from ['"]@modelcontextprotocol\/server['"]/);
expect(result.diagnostics.some(d => d.message.includes('object spread'))).toBe(false);
});

it('rewrites a named re-export of Protocol', () => {
const input = `export { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toMatch(/export \{ Protocol \} from ['"]@modelcontextprotocol\/server['"]/);
expect(result.diagnostics.some(d => d.message.includes('no v2 export'))).toBe(false);
});
});

describe('symbols with no v2 export (removedSymbols)', () => {
// No live mapping uses removedSymbols today (Protocol/mergeCapabilities were the
// last, until they became public exports). The machinery stays for future
// removals; these tests pin it against a synthetic mapping.
const SYNTHETIC = '@modelcontextprotocol/sdk/shared/synthetic.js';

beforeAll(() => {
IMPORT_MAP[SYNTHETIC] = {
target: 'RESOLVE_BY_CONTEXT',
status: 'moved',
removedSymbols: {
GoneClass: 'The GoneClass base class is not exported by the v2 packages. ' + 'See the migration guide for the replacement.',
goneHelper: 'goneHelper() is not exported by the v2 packages. Use a plain object spread.'
}
};
});

afterAll(() => {
delete IMPORT_MAP[SYNTHETIC];
});

it('drops a removed symbol from an import and flags it', () => {
const input = `import { GoneClass } from '${SYNTHETIC}';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).not.toContain('GoneClass }');
expect(text).not.toContain('@modelcontextprotocol/sdk');
const diag = result.diagnostics.find(d => d.insertComment && d.message.includes('GoneClass base class'));
expect(diag).toBeDefined();
expect(diag?.message).toContain('migration guide');
});

it('routes surviving siblings while dropping the removed symbol', () => {
const input = `import { GoneClass, type Survivor } from '${SYNTHETIC}';\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toContain('Survivor');
expect(text).toContain('@modelcontextprotocol/server');
expect(text).not.toMatch(/\bGoneClass\b/);
expect(result.diagnostics.some(d => d.message.includes('GoneClass base class'))).toBe(true);
});

it('flags a removed helper with its configured guidance', () => {
const input = `import { goneHelper } from '${SYNTHETIC}';\n`;
const { result } = applyWithDiagnostics(input);
const diag = result.diagnostics.find(d => d.message.includes('mergeCapabilities'));
const diag = result.diagnostics.find(d => d.message.includes('goneHelper'));
expect(diag).toBeDefined();
expect(diag?.message).toContain('object spread');
});

it('does not resolve a context package for an import of only removed symbols', () => {
const input = `import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const input = `import { GoneClass } from '${SYNTHETIC}';\n`;
const { result } = applyWithDiagnostics(input, { projectType: 'unknown' });
// projectType 'unknown' would emit a could-not-determine warning if context were resolved.
expect(result.diagnostics.some(d => d.message.includes('could not determine'))).toBe(false);
});

it('flags qualified accesses to removed symbols on a namespace import', () => {
const input = `import * as proto from '@modelcontextprotocol/sdk/shared/protocol.js';\nclass Mine extends proto.Protocol {}\n`;
const input = `import * as synth from '${SYNTHETIC}';\nclass Mine extends synth.GoneClass {}\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toContain('@modelcontextprotocol/server');
expect(result.diagnostics.some(d => d.insertComment && d.message.includes('Protocol base class'))).toBe(true);
expect(result.diagnostics.some(d => d.insertComment && d.message.includes('GoneClass base class'))).toBe(true);
});

it('warns on a named re-export of Protocol with module-scoped guidance', () => {
const input = `export { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
it('warns on a named re-export of a removed symbol with module-scoped guidance', () => {
const input = `export { GoneClass } from '${SYNTHETIC}';\n`;
const { result } = applyWithDiagnostics(input);
const diag = result.diagnostics.find(d => d.message.includes('Re-exported Protocol has no v2 export'));
const diag = result.diagnostics.find(d => d.message.includes('Re-exported GoneClass has no v2 export'));
expect(diag).toBeDefined();
expect(diag?.message).toContain('fallbackRequestHandler');
expect(diag?.message).toContain('migration guide');
});

it('flags a star re-export of a module with removed symbols', () => {
const input = `export * from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
const input = `export * from '${SYNTHETIC}';\n`;
const { result } = applyWithDiagnostics(input);
const messages = result.diagnostics.map(d => d.message);
expect(messages.some(m => m.includes('Star re-export') && m.includes('Protocol'))).toBe(true);
expect(messages.some(m => m.includes('Star re-export') && m.includes('mergeCapabilities'))).toBe(true);
expect(messages.some(m => m.includes('Star re-export') && m.includes('GoneClass'))).toBe(true);
expect(messages.some(m => m.includes('Star re-export') && m.includes('goneHelper'))).toBe(true);
});

it('flags type-position namespace accesses (QualifiedName) to removed symbols', () => {
const input = `import * as proto from '@modelcontextprotocol/sdk/shared/protocol.js';\nexport function f(p: proto.Protocol): void {}\n`;
const input = `import * as synth from '${SYNTHETIC}';\nexport function f(p: synth.GoneClass): void {}\n`;
const { text, result } = applyWithDiagnostics(input);
expect(text).toContain('@modelcontextprotocol/server');
expect(result.diagnostics.some(d => d.insertComment && d.message.includes('Protocol base class'))).toBe(true);
expect(result.diagnostics.some(d => d.insertComment && d.message.includes('GoneClass base class'))).toBe(true);
});

it('anchors the dropped-symbol marker to a usage site that survives the rewrite', () => {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile(
'test.ts',
`import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n\nexport class Mine extends Protocol {}\n`
`import { GoneClass } from '${SYNTHETIC}';\n\nexport class Mine extends GoneClass {}\n`
);
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
const diag = result.diagnostics.find(d => d.message.includes('Protocol base class'));
const diag = result.diagnostics.find(d => d.message.includes('GoneClass base class'));
expect(diag).toBeDefined();
// resolveCurrentLine resolves against the live usage node, not the removed import.
const finalLine =
sourceFile
.getFullText()
.split('\n')
.findIndex(l => l.includes('extends Protocol')) + 1;
.findIndex(l => l.includes('extends GoneClass')) + 1;
expect(diag?.resolveCurrentLine?.()).toBe(finalLine);
});
});
Expand Down
Loading
Loading