Skip to content

Commit 1480241

Browse files
Export Protocol and mergeCapabilities from client/server package roots (modelcontextprotocol#2501)
1 parent e81758c commit 1480241

12 files changed

Lines changed: 246 additions & 73 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@modelcontextprotocol/core-internal': minor
3+
'@modelcontextprotocol/client': minor
4+
'@modelcontextprotocol/server': minor
5+
'@modelcontextprotocol/codemod': patch
6+
---
7+
8+
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.
9+
10+
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.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The SDK is organized into three main layers:
6868
The SDK separates internal code from the public API surface:
6969

7070
- **`@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`).
71-
- **`@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.
71+
- **`@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.
7272
- **`@modelcontextprotocol/client`** and **`@modelcontextprotocol/server`** (`packages/*/src/index.ts`) — Final public surface. Package-specific exports (named explicitly) plus re-exports from `core-internal/public`.
7373
- **`@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).
7474

docs/migration/upgrade-to-v2.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,8 @@ In addition the codemod:
108108
`completable(schema, cb).optional()` (see
109109
[Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated)); shapes it
110110
cannot invert get an `@mcp-codemod-error` marker.
111-
- Drops `Protocol` / `mergeCapabilities` from `shared/protocol.js` imports, re-exports,
112-
mocks, and dynamic imports — no v2 package exports them — leaving a marker with the
113-
replacement at each site.
111+
- Rewrites `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
112+
client or server package root, like the module's other symbols.
114113
115114
## What the codemod does NOT handle
116115
@@ -1444,13 +1443,15 @@ now typed as JSON-compatible objects (nested JSON values) rather than arbitrary
14441443
objects. A payload typed `Record<string, unknown>` no longer assigns (`TS2322`) — give
14451444
the source a JSON-compatible type or cast at the boundary.
14461445
1447-
The `Protocol` base class itself is no longer exported (it is internal engine). If you
1448-
were reaching into protocol internals — rare, mostly debugging tools —
1449-
`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every
1450-
inbound request that no registered handler matches, before capability gating. Delete
1451-
the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod
1452-
drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves an
1453-
`@mcp-codemod-error` marker at the site explaining the replacement.
1446+
The `Protocol` base class and `mergeCapabilities` moved: import them from the
1447+
`@modelcontextprotocol/client` or `@modelcontextprotocol/server` package root instead
1448+
of `shared/protocol.js`. Most code should not use `Protocol` directly — `Client` and
1449+
`Server` are the supported surfaces, and for observing unmatched inbound requests
1450+
prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`. The codemod
1451+
rewrites `Protocol` and `mergeCapabilities` imports to the package root, like the
1452+
module's other symbols. One caveat: the client and server packages each bundle their
1453+
own compiled copy of the class, so the two roots' `Protocol` exports are distinct
1454+
classes — import it from one package consistently within a process.
14541455
14551456
#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106)
14561457
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Pins that `Protocol` and `mergeCapabilities` are exported from the package
3+
* root (carried by the `export *` of the core-internal public barrel).
4+
*/
5+
import { describe, expect, test } from 'vitest';
6+
7+
import { mergeCapabilities, Protocol } from '../../src/index';
8+
9+
describe('package root exports', () => {
10+
test('Protocol and mergeCapabilities are exported from the client root', () => {
11+
expect(typeof Protocol).toBe('function');
12+
expect(typeof mergeCapabilities).toBe('function');
13+
});
14+
});

packages/codemod/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ guards that mix the two enums), add `import { z } from 'zod'` when a wrap needs
4242
it, rewrite `vi.mock`
4343
/ `jest.mock` / dynamic `import()` paths, invert optional completable nesting
4444
(`completable(schema.optional(), cb)` becomes `completable(schema, cb).optional()`),
45-
and drop `Protocol` / `mergeCapabilities` (no v2 export) with an action-required
46-
marker naming the replacement.
45+
and rewrite `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the
46+
client or server package root.
4747

4848
## `@mcp-codemod-error` markers
4949

packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,7 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
173173
},
174174
'@modelcontextprotocol/sdk/shared/protocol.js': {
175175
target: 'RESOLVE_BY_CONTEXT',
176-
status: 'moved',
177-
removedSymbols: {
178-
Protocol:
179-
'The Protocol base class is not exported by the v2 packages. To observe or handle inbound requests ' +
180-
'that have no registered handler, use client.fallbackRequestHandler / server.fallbackRequestHandler; ' +
181-
'build custom behavior on Client or Server instead of subclassing Protocol. ' +
182-
'See the migration guide: Behavioral changes > Client connection & dispatch.',
183-
mergeCapabilities:
184-
'mergeCapabilities() is not exported by the v2 packages. Pass the complete capabilities object to the ' +
185-
'Client/Server constructor, or merge capability objects with a plain object spread.'
186-
}
176+
status: 'moved'
187177
},
188178
'@modelcontextprotocol/sdk/shared/transport.js': {
189179
target: 'RESOLVE_BY_CONTEXT',

packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,9 @@ export const importPathsTransform: Transform = {
225225
);
226226
}
227227
}
228-
// Qualified accesses to symbols with no v2 export (`ns.Protocol`) can't be fixed by
228+
// Qualified accesses to symbols with no v2 export (`ns.GoneClass`) can't be fixed by
229229
// moving the namespace binding — flag each accessed one. Expression positions are
230-
// PropertyAccessExpressions; type positions (`let p: ns.Protocol`) are QualifiedNames.
230+
// PropertyAccessExpressions; type positions (`let p: ns.GoneClass`) are QualifiedNames.
231231
if (mapping.removedSymbols) {
232232
const nsName = namespaceImport.getText();
233233
const accessedRemoved = new Map<string, Node>();

packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22
import { Project } from 'ts-morph';
33

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

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

15+
function applyWithDiagnostics(code: string, context: TransformContext = { projectType: 'server' }) {
16+
const project = new Project({ useInMemoryFileSystem: true });
17+
const sourceFile = project.createSourceFile('test.ts', code);
18+
const result = importPathsTransform.apply(sourceFile, context);
19+
return { text: sourceFile.getFullText(), result };
20+
}
21+
1422
describe('import-paths transform', () => {
1523
it('rewrites client imports to @modelcontextprotocol/client', () => {
1624
const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`;
@@ -1006,93 +1014,139 @@ describe('auth types routing (B3)', () => {
10061014
});
10071015
});
10081016

1009-
describe('symbols with no v2 export (removedSymbols)', () => {
1010-
function applyWithDiagnostics(code: string, context: TransformContext = { projectType: 'server' }) {
1011-
const project = new Project({ useInMemoryFileSystem: true });
1012-
const sourceFile = project.createSourceFile('test.ts', code);
1013-
const result = importPathsTransform.apply(sourceFile, context);
1014-
return { text: sourceFile.getFullText(), result };
1015-
}
1016-
1017-
it('drops Protocol from a shared/protocol.js import and flags it', () => {
1017+
describe('Protocol and mergeCapabilities route like other shared/protocol.js symbols', () => {
1018+
it('rewrites a Protocol import to the context package root', () => {
10181019
const input = `import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
10191020
const { text, result } = applyWithDiagnostics(input);
1020-
expect(text).not.toContain('Protocol }');
1021+
expect(text).toMatch(/import \{ Protocol \} from ['"]@modelcontextprotocol\/server['"]/);
10211022
expect(text).not.toContain('@modelcontextprotocol/sdk');
1022-
const diag = result.diagnostics.find(d => d.insertComment && d.message.includes('Protocol base class'));
1023-
expect(diag).toBeDefined();
1024-
expect(diag?.message).toContain('fallbackRequestHandler');
1023+
expect(result.diagnostics.filter(d => d.insertComment)).toEqual([]);
10251024
});
10261025

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

1036-
it('flags mergeCapabilities with spread guidance', () => {
1035+
it('rewrites a mergeCapabilities import with no spread guidance', () => {
10371036
const input = `import { mergeCapabilities } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
1037+
const { text, result } = applyWithDiagnostics(input);
1038+
expect(text).toMatch(/import \{ mergeCapabilities \} from ['"]@modelcontextprotocol\/server['"]/);
1039+
expect(result.diagnostics.some(d => d.message.includes('object spread'))).toBe(false);
1040+
});
1041+
1042+
it('rewrites a named re-export of Protocol', () => {
1043+
const input = `export { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`;
1044+
const { text, result } = applyWithDiagnostics(input);
1045+
expect(text).toMatch(/export \{ Protocol \} from ['"]@modelcontextprotocol\/server['"]/);
1046+
expect(result.diagnostics.some(d => d.message.includes('no v2 export'))).toBe(false);
1047+
});
1048+
});
1049+
1050+
describe('symbols with no v2 export (removedSymbols)', () => {
1051+
// No live mapping uses removedSymbols today (Protocol/mergeCapabilities were the
1052+
// last, until they became public exports). The machinery stays for future
1053+
// removals; these tests pin it against a synthetic mapping.
1054+
const SYNTHETIC = '@modelcontextprotocol/sdk/shared/synthetic.js';
1055+
1056+
beforeAll(() => {
1057+
IMPORT_MAP[SYNTHETIC] = {
1058+
target: 'RESOLVE_BY_CONTEXT',
1059+
status: 'moved',
1060+
removedSymbols: {
1061+
GoneClass: 'The GoneClass base class is not exported by the v2 packages. ' + 'See the migration guide for the replacement.',
1062+
goneHelper: 'goneHelper() is not exported by the v2 packages. Use a plain object spread.'
1063+
}
1064+
};
1065+
});
1066+
1067+
afterAll(() => {
1068+
delete IMPORT_MAP[SYNTHETIC];
1069+
});
1070+
1071+
it('drops a removed symbol from an import and flags it', () => {
1072+
const input = `import { GoneClass } from '${SYNTHETIC}';\n`;
1073+
const { text, result } = applyWithDiagnostics(input);
1074+
expect(text).not.toContain('GoneClass }');
1075+
expect(text).not.toContain('@modelcontextprotocol/sdk');
1076+
const diag = result.diagnostics.find(d => d.insertComment && d.message.includes('GoneClass base class'));
1077+
expect(diag).toBeDefined();
1078+
expect(diag?.message).toContain('migration guide');
1079+
});
1080+
1081+
it('routes surviving siblings while dropping the removed symbol', () => {
1082+
const input = `import { GoneClass, type Survivor } from '${SYNTHETIC}';\n`;
1083+
const { text, result } = applyWithDiagnostics(input);
1084+
expect(text).toContain('Survivor');
1085+
expect(text).toContain('@modelcontextprotocol/server');
1086+
expect(text).not.toMatch(/\bGoneClass\b/);
1087+
expect(result.diagnostics.some(d => d.message.includes('GoneClass base class'))).toBe(true);
1088+
});
1089+
1090+
it('flags a removed helper with its configured guidance', () => {
1091+
const input = `import { goneHelper } from '${SYNTHETIC}';\n`;
10381092
const { result } = applyWithDiagnostics(input);
1039-
const diag = result.diagnostics.find(d => d.message.includes('mergeCapabilities'));
1093+
const diag = result.diagnostics.find(d => d.message.includes('goneHelper'));
10401094
expect(diag).toBeDefined();
10411095
expect(diag?.message).toContain('object spread');
10421096
});
10431097

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

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

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

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

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

10811135
it('anchors the dropped-symbol marker to a usage site that survives the rewrite', () => {
10821136
const project = new Project({ useInMemoryFileSystem: true });
10831137
const sourceFile = project.createSourceFile(
10841138
'test.ts',
1085-
`import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';\n\nexport class Mine extends Protocol {}\n`
1139+
`import { GoneClass } from '${SYNTHETIC}';\n\nexport class Mine extends GoneClass {}\n`
10861140
);
10871141
const result = importPathsTransform.apply(sourceFile, { projectType: 'server' });
1088-
const diag = result.diagnostics.find(d => d.message.includes('Protocol base class'));
1142+
const diag = result.diagnostics.find(d => d.message.includes('GoneClass base class'));
10891143
expect(diag).toBeDefined();
10901144
// resolveCurrentLine resolves against the live usage node, not the removed import.
10911145
const finalLine =
10921146
sourceFile
10931147
.getFullText()
10941148
.split('\n')
1095-
.findIndex(l => l.includes('extends Protocol')) + 1;
1149+
.findIndex(l => l.includes('extends GoneClass')) + 1;
10961150
expect(diag?.resolveCurrentLine?.()).toBe(finalLine);
10971151
});
10981152
});

0 commit comments

Comments
 (0)