From f60b40179b0f090f6d64248be1312245a160b5ee Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Jul 2026 08:20:21 +0000 Subject: [PATCH] Export Protocol and mergeCapabilities from client/server package roots Consumers that run their own JSON-RPC protocol on the Protocol engine (e.g. the MCP Apps SDK's ui/initialize handshake over postMessage) have no v2 extension point: subclassing Client/Server forces the MCP initialize handshake onto channels whose deployed peers do not speak it. Restore the v1 extension point by exporting Protocol and mergeCapabilities from the curated public barrel, which both package roots re-export. Protocol.connect() wires the transport without any handshake, and non-spec methods pass the wire layer without era gating, so the v1 wire posture is fully expressible. The codemod now rewrites Protocol/mergeCapabilities imports from shared/protocol.js to the package root like the module's other symbols; its removedSymbols machinery keeps coverage via a synthetic mapping. Root-level export pins prevent silent regression, and the migration guide documents the subclass shape changes and the cross-bundle class identity caveat (each root bundles its own copy). --- .changeset/export-protocol-class.md | 14 ++ CLAUDE.md | 2 +- docs/migration/upgrade-to-v2.md | 33 +++-- .../client/test/client/protocolExport.test.ts | 17 +++ packages/codemod/README.md | 4 +- .../migrations/v1-to-v2/mappings/importMap.ts | 12 +- .../v1-to-v2/transforms/importPaths.ts | 4 +- .../v1-to-v2/transforms/importPaths.test.ts | 122 +++++++++++++----- .../v1-to-v2/transforms/mockPaths.test.ts | 47 +++++-- .../core-internal/src/exports/public/index.ts | 12 +- packages/core-internal/src/shared/protocol.ts | 6 +- .../test/shared/publicProtocol.test.ts | 68 ++++++++++ .../server/test/server/protocolExport.test.ts | 17 +++ 13 files changed, 284 insertions(+), 74 deletions(-) create mode 100644 .changeset/export-protocol-class.md create mode 100644 packages/client/test/client/protocolExport.test.ts create mode 100644 packages/core-internal/test/shared/publicProtocol.test.ts create mode 100644 packages/server/test/server/protocolExport.test.ts diff --git a/.changeset/export-protocol-class.md b/.changeset/export-protocol-class.md new file mode 100644 index 0000000000..5586844a98 --- /dev/null +++ b/.changeset/export-protocol-class.md @@ -0,0 +1,14 @@ +--- +'@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. + +Some SDK consumers build their own JSON-RPC protocols on the `Protocol` engine rather than speaking MCP itself — ext-apps (MCP Apps) runs its `ui/initialize` handshake over an iframe postMessage channel this way. Subclassing `Client`/`Server` forces the core MCP `initialize` handshake onto such channels, which their deployed peers do not understand. Exporting `Protocol` restores the v1 extension point: `Protocol.connect()` wires the transport without any handshake, and non-spec methods pass through the wire layer without era gating. + +The supporting protocol types (`ProtocolOptions`, `BaseContext`, `RequestOptions`, `Transport`, …) were already public. + +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. diff --git a/CLAUDE.md b/CLAUDE.md index c7fa2357de..36ad845263 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) for consumers that build custom JSON-RPC protocols on the engine. 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). diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 3041bd7567..a2fe3bd14f 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -108,9 +108,9 @@ In addition the codemod: `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 (see + [Removed type aliases](#removed-type-aliases) for the subclass shape changes). ## What the codemod does NOT handle @@ -1444,13 +1444,26 @@ now typed as JSON-compatible objects (nested JSON values) rather than arbitrary objects. A payload typed `Record` 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`. Extending `Protocol` directly remains the right shape for +consumers that run their own JSON-RPC protocol on the SDK engine (custom methods, own +handshake) — `Protocol.connect()` wires the transport without performing the MCP +`initialize` handshake. Subclasses adapt to the v2 shape: one `ContextT` type +parameter instead of the three message generics, a `buildContext` implementation +(identity is fine), and method-string-keyed handler registration (see +[`setRequestHandler` / `setNotificationHandler` use method strings](#setrequesthandler--setnotificationhandler-use-method-strings)). If you +were only reaching into protocol internals for +debugging, prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`, +which receives every inbound request that no registered handler matches, before +capability gating. 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 — `instanceof` returns `false` across the two +copies, and their type declarations are nominally incompatible (the class has private +members). #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) diff --git a/packages/client/test/client/protocolExport.test.ts b/packages/client/test/client/protocolExport.test.ts new file mode 100644 index 0000000000..016ba173a8 --- /dev/null +++ b/packages/client/test/client/protocolExport.test.ts @@ -0,0 +1,17 @@ +/** + * Pins that the `Protocol` base class and `mergeCapabilities` are exported from + * the package ROOT — not just from the core-internal public barrel. The root's + * `export * from '@modelcontextprotocol/core-internal/public'` is what carries + * them; replacing it with named exports (or adding a colliding explicit export) + * would silently drop them while the barrel-level pin stays green. + */ +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'); + }); +}); diff --git a/packages/codemod/README.md b/packages/codemod/README.md index 2c89d2de24..33aaf88a81 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -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 diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index 2b49222d75..17f5740cfb 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -173,17 +173,7 @@ export const IMPORT_MAP: Record = { }, '@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' }, '@modelcontextprotocol/sdk/shared/transport.js': { target: 'RESOLVE_BY_CONTEXT', diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 3b70c4ca99..1d05a307f1 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -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(); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index ee5d889cee..933cf02e29 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -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'; @@ -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`; @@ -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); }); }); diff --git a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts index 9776135d09..2716ed81c8 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -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 { mockPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/mockPaths'; import type { TransformContext } from '../../../src/types'; @@ -578,19 +579,49 @@ describe('removed symbols in mocks and dynamic imports', () => { return { text: sourceFile.getFullText(), result }; } - it('leaves a mock factory providing Protocol unrewritten and flags it', () => { - const input = `vi.mock('@modelcontextprotocol/sdk/shared/protocol.js', () => ({ Protocol: class {} }));\n`; + 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.' + } + }; + }); + + afterAll(() => { + delete IMPORT_MAP[SYNTHETIC]; + }); + + it('rewrites a mock factory providing Protocol like other surviving symbols', () => { + const input = `vi.mock('@modelcontextprotocol/sdk/shared/protocol.js', () => ({ Protocol: class {} }));\nimport '@modelcontextprotocol/sdk/server/mcp.js';\n`; + const { text, result } = applyWithDiagnostics(input); + expect(text).toContain(`vi.mock('@modelcontextprotocol/server'`); + expect(result.diagnostics.filter(d => d.insertComment)).toEqual([]); + }); + + it('rewrites a dynamic import destructuring Protocol', () => { + const input = `const { Protocol } = await import('@modelcontextprotocol/sdk/shared/protocol.js');\nexport { Protocol };\n`; const { text, result } = applyWithDiagnostics(input); - expect(text).toContain('@modelcontextprotocol/sdk/shared/protocol.js'); + expect(text).toContain(`await import('@modelcontextprotocol/server')`); + expect(result.diagnostics.filter(d => d.insertComment)).toEqual([]); + }); + + it('leaves a mock factory providing a removed symbol unrewritten and flags it', () => { + const input = `vi.mock('${SYNTHETIC}', () => ({ GoneClass: class {} }));\n`; + const { text, result } = applyWithDiagnostics(input); + expect(text).toContain(SYNTHETIC); const diag = result.diagnostics.find(d => d.insertComment); - expect(diag?.message).toContain('Protocol'); + expect(diag?.message).toContain('GoneClass'); expect(diag?.message).toContain('no v2 package exports'); }); - it('leaves a dynamic import destructuring Protocol unrewritten and flags it', () => { - const input = `const { Protocol } = await import('@modelcontextprotocol/sdk/shared/protocol.js');\nexport { Protocol };\n`; + it('leaves a dynamic import destructuring a removed symbol unrewritten and flags it', () => { + const input = `const { GoneClass } = await import('${SYNTHETIC}');\nexport { GoneClass };\n`; const { text, result } = applyWithDiagnostics(input); - expect(text).toContain('@modelcontextprotocol/sdk/shared/protocol.js'); + expect(text).toContain(SYNTHETIC); const diag = result.diagnostics.find(d => d.insertComment); expect(diag?.message).toContain('undefined at runtime'); }); diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 06f9b829f4..f7252a5ddf 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -4,9 +4,10 @@ * This module defines the stable, public-facing API surface. Client and server * packages re-export from here so that end users only see supported symbols. * - * Internal utilities (Protocol class, stdio parsing, schema helpers, etc.) - * remain available via the internal barrel (@modelcontextprotocol/core-internal) for - * use by client/server packages. + * Internal utilities (stdio parsing, schema helpers, etc.) remain available via the + * internal barrel (@modelcontextprotocol/core-internal) for use by client/server + * packages. The Protocol class itself is public: consumers that run their own + * JSON-RPC protocol on the SDK engine extend it directly. */ // Auth error classes @@ -45,7 +46,8 @@ export { isJsonContentType } from '../../shared/mediaType'; // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; -// Protocol types (NOT the Protocol class itself or mergeCapabilities) +// Protocol types and the Protocol base class (for consumers that build their own +// JSON-RPC protocols on the SDK engine, e.g. ext-apps) export type { BaseContext, ClientContext, @@ -57,7 +59,7 @@ export type { RequestStateAccessor, ServerContext } from '../../shared/protocol'; -export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol'; +export { DEFAULT_REQUEST_TIMEOUT_MSEC, mergeCapabilities, Protocol } from '../../shared/protocol'; // stdio message framing utilities (for custom transport authors) export { deserializeMessage, ReadBuffer, serializeMessage, STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../shared/stdio'; diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index ffab6e8df8..e23f69b245 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -552,7 +552,11 @@ export function setNegotiatedProtocolVersion( * features like request/response linking, notifications, and progress. * * `Protocol` is abstract; `Client` and `Server` are the concrete role-specific - * implementations most code should use. + * implementations most code should use. Consumers that run their own JSON-RPC + * protocol on the engine (custom methods, own handshake — e.g. the MCP Apps SDK) + * extend `Protocol` directly instead: `connect()` wires the transport without + * performing the MCP `initialize` handshake. Subclasses implement `buildContext` + * (identity is fine) and the three capability assertion hooks. */ export abstract class Protocol { private _transport?: Transport; diff --git a/packages/core-internal/test/shared/publicProtocol.test.ts b/packages/core-internal/test/shared/publicProtocol.test.ts new file mode 100644 index 0000000000..a972d47872 --- /dev/null +++ b/packages/core-internal/test/shared/publicProtocol.test.ts @@ -0,0 +1,68 @@ +/** + * Behavior-surface pin: the `Protocol` base class is public API. + * + * Consumers that run their own JSON-RPC protocol on the SDK engine (e.g. the + * MCP Apps SDK's `ui/initialize` handshake over postMessage) extend `Protocol` + * directly instead of `Client`/`Server`, because the role classes force the + * MCP `initialize` handshake onto channels whose deployed peers do not speak + * it. This pins the two properties that contract depends on: + * + * 1. `Protocol` and `mergeCapabilities` are exported from the curated + * public barrel (and therefore from the client and server package roots). + * 2. A `Protocol` subclass pair can connect and exchange a custom method + * without any MCP `initialize` appearing on the wire. + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/exports/public/index'; +import { InMemoryTransport, mergeCapabilities, Protocol } from '../../src/exports/public/index'; +import type { JSONRPCMessage } from '../../src/types/types'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} +} + +describe('public Protocol class', () => { + test('Protocol and mergeCapabilities are exported from the public barrel', () => { + expect(typeof Protocol).toBe('function'); + expect(typeof mergeCapabilities).toBe('function'); + }); + + test('a Protocol subclass exchanges custom methods with no initialize on the wire', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Capture BOTH directions: the accepting side must be as silent as the + // requesting side — deployed peers understand nothing but the custom + // methods. + const sentByA: string[] = []; + const sentByB: string[] = []; + const captureSends = (transport: InMemoryTransport, into: string[]) => { + const originalSend = transport.send.bind(transport); + transport.send = async (message: JSONRPCMessage, options) => { + if ('method' in message) into.push(message.method); + return originalSend(message, options); + }; + }; + captureSends(clientTransport, sentByA); + captureSends(serverTransport, sentByB); + + const a = new TestProtocol(); + const b = new TestProtocol(); + b.setRequestHandler('acme/echo', { params: z.object({ value: z.string() }) }, params => ({ + echoed: params.value + })); + + await b.connect(serverTransport); + await a.connect(clientTransport); + const result = await a.request({ method: 'acme/echo', params: { value: 'hi' } }, z.object({ echoed: z.string() })); + + expect(result).toEqual({ echoed: 'hi' }); + expect(sentByA).toEqual(['acme/echo']); + expect(sentByB).toEqual([]); + }); +}); diff --git a/packages/server/test/server/protocolExport.test.ts b/packages/server/test/server/protocolExport.test.ts new file mode 100644 index 0000000000..1e9356c63f --- /dev/null +++ b/packages/server/test/server/protocolExport.test.ts @@ -0,0 +1,17 @@ +/** + * Pins that the `Protocol` base class and `mergeCapabilities` are exported from + * the package ROOT — not just from the core-internal public barrel. The root's + * `export * from '@modelcontextprotocol/core-internal/public'` is what carries + * them; replacing it with named exports (or adding a colliding explicit export) + * would silently drop them while the barrel-level pin stays green. + */ +import { describe, expect, test } from 'vitest'; + +import { mergeCapabilities, Protocol } from '../../src/index'; + +describe('package root exports', () => { + test('Protocol and mergeCapabilities are exported from the server root', () => { + expect(typeof Protocol).toBe('function'); + expect(typeof mergeCapabilities).toBe('function'); + }); +});