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'); + }); +});