diff --git a/eslint.config.mjs b/eslint.config.mjs index a22fc789e..088bc6f9a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -65,7 +65,7 @@ export default [ '@frontmcp/protocol', '@frontmcp/testing', '@frontmcp/observability', - '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path)$', + '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client)$', ], depConstraints: [ { diff --git a/libs/protocol/jest.config.ts b/libs/protocol/jest.config.ts index d2dea32e7..20394fba0 100644 --- a/libs/protocol/jest.config.ts +++ b/libs/protocol/jest.config.ts @@ -7,6 +7,7 @@ module.exports = { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/unit/protocol', + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'], coverageThreshold: { global: { statements: 95, diff --git a/libs/protocol/package.json b/libs/protocol/package.json index 76951438b..1e0764f88 100644 --- a/libs/protocol/package.json +++ b/libs/protocol/package.json @@ -53,6 +53,14 @@ "worker": "./src/browser-stdio-server.ts", "browser": "./src/browser-stdio-server.ts", "default": "./src/node-stdio-server.ts" + }, + "#client": { + "types": "./src/client.ts", + "worker": "./src/client.ts", + "browser": "./src/client.ts", + "require": "./src/lazy-client.ts", + "import": "./src/client.ts", + "default": "./src/client.ts" } }, "type": "commonjs", diff --git a/libs/protocol/project.json b/libs/protocol/project.json index 045db8999..15b259862 100644 --- a/libs/protocol/project.json +++ b/libs/protocol/project.json @@ -28,7 +28,9 @@ "libs/protocol/src/node-stdio-client.ts", "libs/protocol/src/browser-stdio-client.ts", "libs/protocol/src/node-stdio-server.ts", - "libs/protocol/src/browser-stdio-server.ts" + "libs/protocol/src/browser-stdio-server.ts", + "libs/protocol/src/client.ts", + "libs/protocol/src/lazy-client.ts" ], "assets": ["libs/protocol/README.md", "libs/protocol/CHANGELOG.md", "LICENSE", "libs/protocol/package.json"], "esbuildOptions": { @@ -42,7 +44,8 @@ "#mcp-server", "#server-types", "#stdio-client", - "#stdio-server" + "#stdio-server", + "#client" ] } }, @@ -71,7 +74,9 @@ "libs/protocol/src/node-stdio-client.ts", "libs/protocol/src/browser-stdio-client.ts", "libs/protocol/src/node-stdio-server.ts", - "libs/protocol/src/browser-stdio-server.ts" + "libs/protocol/src/browser-stdio-server.ts", + "libs/protocol/src/client.ts", + "libs/protocol/src/lazy-client.ts" ], "esbuildOptions": { "outExtension": { @@ -84,7 +89,8 @@ "#mcp-server", "#server-types", "#stdio-client", - "#stdio-server" + "#stdio-server", + "#client" ] } } @@ -95,6 +101,7 @@ "options": { "commands": [ "node scripts/strip-dist-from-pkg.js libs/protocol/dist/package.json && node scripts/fix-protocol-esm-reexports.js", + "node scripts/inject-esm-require-banner.js libs/protocol/dist/esm", "node scripts/escape-non-ascii.mjs libs/protocol/dist" ], "parallel": false diff --git a/libs/protocol/src/__tests__/exports.spec.ts b/libs/protocol/src/__tests__/exports.spec.ts new file mode 100644 index 000000000..e96697502 --- /dev/null +++ b/libs/protocol/src/__tests__/exports.spec.ts @@ -0,0 +1,73 @@ +import { IncomingMessage as NodeIncomingMessage, ServerResponse as NodeServerResponse } from 'http'; + +import { InMemoryTransport as SdkInMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Server as SdkServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport as SdkStdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport as SdkStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { WebStandardStreamableHTTPServerTransport as SdkWebStandardTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; + +import { Server as BrowserServer } from '../browser-mcp-server'; +import * as browserStreamableHttp from '../browser-mcp-streamable-http'; +import * as browserServerTypes from '../browser-server-types'; +import { StdioClientTransport as BrowserStdioClientTransport } from '../browser-stdio-client'; +import { StdioServerTransport as BrowserStdioServerTransport } from '../browser-stdio-server'; +import { InMemoryTransport } from '../in-memory'; +import * as protocol from '../index'; +import { Server as NodeServer } from '../node-mcp-server'; +import * as nodeStreamableHttp from '../node-mcp-streamable-http'; +import * as nodeServerTypes from '../node-server-types'; +import { StdioServerTransport as NodeStdioServerTransport } from '../node-stdio-server'; +import { StdioServerTransport } from '../stdio'; +import { isProtocol20260728, PROTOCOL_2026_07_28, readDeclaredProtocolVersion } from '../types-20260728'; + +describe('@frontmcp/protocol exports', () => { + it('re-exports the MCP SDK server, transports and types for Node', () => { + expect(NodeServer).toBe(SdkServer); + expect(BrowserServer).toBe(SdkServer); + expect(nodeStreamableHttp.StreamableHTTPServerTransport).toBe(SdkStreamableHTTPServerTransport); + expect(nodeStreamableHttp.WebStandardStreamableHTTPServerTransport).toBe(SdkWebStandardTransport); + expect(NodeStdioServerTransport).toBe(SdkStdioServerTransport); + expect(StdioServerTransport).toBe(SdkStdioServerTransport); + expect(InMemoryTransport).toBe(SdkInMemoryTransport); + expect(nodeServerTypes.IncomingMessage).toBe(NodeIncomingMessage); + expect(nodeServerTypes.HttpServerResponse).toBe(NodeServerResponse); + }); + + it('exports the protocol surface from the package root', () => { + expect(protocol.LATEST_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION); + expect(protocol.PROTOCOL_2026_07_28).toBe('2026-07-28'); + expect(protocol.StdioServerTransport).toBe(SdkStdioServerTransport); + expect(typeof protocol.Client).toBe('function'); + expect(typeof protocol.StreamableHTTPClientTransport).toBe('function'); + }); + + it('gives browsers stand-ins that say what is not available', () => { + expect(() => new browserStreamableHttp.StreamableHTTPServerTransport()).toThrow( + 'StreamableHTTPServerTransport is not available in browser environments', + ); + expect(() => new browserStreamableHttp.WebStandardStreamableHTTPServerTransport()).toThrow( + 'WebStandardStreamableHTTPServerTransport is not available in browser environments', + ); + expect(() => new BrowserStdioClientTransport()).toThrow( + 'StdioClientTransport is not available in browser environments', + ); + expect(() => new BrowserStdioServerTransport()).toThrow( + 'StdioServerTransport is not available in browser environments', + ); + expect(new browserServerTypes.IncomingMessage().headers).toEqual({}); + expect(new browserServerTypes.HttpServerResponse().end()).toBeUndefined(); + }); + + it('reads the 2026-07-28 protocol version a request declares', () => { + expect(isProtocol20260728(PROTOCOL_2026_07_28)).toBe(true); + expect(isProtocol20260728('2025-11-25')).toBe(false); + const declared = { params: { _meta: { 'io.modelcontextprotocol/protocolVersion': PROTOCOL_2026_07_28 } } }; + expect(readDeclaredProtocolVersion(declared)).toBe(PROTOCOL_2026_07_28); + expect( + readDeclaredProtocolVersion({ params: { _meta: { 'io.modelcontextprotocol/protocolVersion': 7 } } }), + ).toBeUndefined(); + expect(readDeclaredProtocolVersion({ params: {} })).toBeUndefined(); + expect(readDeclaredProtocolVersion(undefined)).toBeUndefined(); + }); +}); diff --git a/libs/protocol/src/__tests__/lazy-client.spec.ts b/libs/protocol/src/__tests__/lazy-client.spec.ts new file mode 100644 index 000000000..27a9f2cb4 --- /dev/null +++ b/libs/protocol/src/__tests__/lazy-client.spec.ts @@ -0,0 +1,38 @@ +const CLIENT_EXPORTS = [ + 'Client', + 'StreamableHTTPClientTransport', + 'SSEClientTransport', + 'StdioClientTransport', +] as const; +const MCP_CLIENT_MODULE = '@modelcontextprotocol/sdk/client/index.js'; + +describe('lazy-client', () => { + afterEach(() => { + jest.dontMock(MCP_CLIENT_MODULE); + jest.resetModules(); + }); + + it('loads the MCP SDK client the first time Client is read', () => { + const loadClientModule = jest.fn(() => ({ Client: class Client {} })); + jest.doMock(MCP_CLIENT_MODULE, loadClientModule); + + jest.isolateModules(() => { + const lazyClient = require('../lazy-client'); + expect(loadClientModule).not.toHaveBeenCalled(); + + expect(lazyClient.Client.name).toBe('Client'); + expect(loadClientModule).toHaveBeenCalledTimes(1); + }); + }); + + it('exports the classes the eager client module exports', () => { + const lazyClient = require('../lazy-client'); + const eagerClient = require('../client'); + + expect(Object.keys(lazyClient).sort()).toEqual([...CLIENT_EXPORTS].sort()); + for (const name of CLIENT_EXPORTS) { + expect(lazyClient[name]).toBe(eagerClient[name]); + } + expect(typeof lazyClient.Client.prototype.connect).toBe('function'); + }); +}); diff --git a/libs/protocol/src/index.ts b/libs/protocol/src/index.ts index a98c9cb0f..47ab626a6 100644 --- a/libs/protocol/src/index.ts +++ b/libs/protocol/src/index.ts @@ -20,7 +20,7 @@ export * from './auth-types'; export * from './shared'; // MCP Client -export * from './client'; +export * from '#client'; // Stdio transport export * from './stdio'; diff --git a/libs/protocol/src/lazy-client.ts b/libs/protocol/src/lazy-client.ts new file mode 100644 index 000000000..e2bc47e9f --- /dev/null +++ b/libs/protocol/src/lazy-client.ts @@ -0,0 +1,19 @@ +// The `require` variant of `#client`: each export loads its MCP SDK module the first time it is read, so a server never loads the client. +Object.defineProperties(exports, { + Client: { + enumerable: true, + get: () => require('@modelcontextprotocol/sdk/client/index.js').Client, + }, + StreamableHTTPClientTransport: { + enumerable: true, + get: () => require('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport, + }, + SSEClientTransport: { + enumerable: true, + get: () => require('@modelcontextprotocol/sdk/client/sse.js').SSEClientTransport, + }, + StdioClientTransport: { + enumerable: true, + get: () => require('#stdio-client').StdioClientTransport, + }, +}); diff --git a/scripts/strip-dist-from-pkg.js b/scripts/strip-dist-from-pkg.js index f0357c460..54f01b92c 100644 --- a/scripts/strip-dist-from-pkg.js +++ b/scripts/strip-dist-from-pkg.js @@ -60,7 +60,8 @@ if (pkg.exports) { // Fix imports map deeply (strip "./dist/" + remove "development") if (pkg.imports) { const cleaned = walk(pkg.imports); - if (cleaned !== undefined) pkg.imports = withEsmBrowserTargets(cleaned, path.dirname(pkgPath)); + const distDir = path.dirname(pkgPath); + if (cleaned !== undefined) pkg.imports = withEsmImportTargets(withEsmBrowserTargets(cleaned, distDir), distDir); else delete pkg.imports; } @@ -91,6 +92,21 @@ function withEsmBrowserTargets(imports, distDir) { return out; } +// An `import` target is built as CommonJS (.js) next to its ESM twin (esm/*.mjs); an ESM importer gets the twin. +function withEsmImportTargets(imports, distDir) { + const out = {}; + for (const [specifier, entry] of Object.entries(imports)) { + const target = entry?.import; + const esmTarget = + typeof target === 'string' ? target.replace(/^\.\//, './esm/').replace(/\.js$/, '.mjs') : undefined; + out[specifier] = + esmTarget && target.endsWith('.js') && fs.existsSync(path.join(distDir, esmTarget)) + ? { ...entry, import: esmTarget } + : entry; + } + return out; +} + // Fix bin map deeply (strip "./dist/") if (pkg.bin) { const cleaned = walk(pkg.bin); diff --git a/scripts/strip-dist-from-pkg.test.mjs b/scripts/strip-dist-from-pkg.test.mjs index 99a1c50e6..e858df4df 100644 --- a/scripts/strip-dist-from-pkg.test.mjs +++ b/scripts/strip-dist-from-pkg.test.mjs @@ -131,6 +131,23 @@ test('#681: a browser subpath import resolves to its ESM twin for an ESM importe }); }); +test('an import subpath target resolves to its ESM twin, and the require target stays CommonJS', async () => { + const { pkg } = await runOnDist( + { + name: 'x', + imports: { + '#client': { require: './src/lazy-client.ts', import: './src/client.ts', default: './src/client.ts' }, + }, + }, + ['client.js', 'lazy-client.js', 'esm/client.mjs'], + ); + assert.deepEqual(pkg.imports['#client'], { + require: './lazy-client.js', + import: './esm/client.mjs', + default: './client.js', + }); +}); + test('#681: a browser subpath import without an ESM twin is left as it is', async () => { const { pkg } = await runOnDist( { name: 'x', imports: { '#host': { browser: './src/host/browser.ts', default: './src/host/node.ts' } } }, diff --git a/scripts/worker-export-conditions.test.mjs b/scripts/worker-export-conditions.test.mjs index bd08ead30..179d3a63a 100644 --- a/scripts/worker-export-conditions.test.mjs +++ b/scripts/worker-export-conditions.test.mjs @@ -58,6 +58,7 @@ const EXPECT = { '#server-types': 'node', '#stdio-client': 'stub', '#stdio-server': 'stub', + '#client': 'node', }, 'libs/sdk': { '#sse-transport': 'stub', diff --git a/tsconfig.base.json b/tsconfig.base.json index 628e6ae58..91724680c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -104,6 +104,7 @@ "#server-types": ["libs/protocol/src/node-server-types.ts"], "#stdio-client": ["libs/protocol/src/node-stdio-client.ts"], "#stdio-server": ["libs/protocol/src/node-stdio-server.ts"], + "#client": ["libs/protocol/src/client.ts"], "#crypto-provider": ["libs/utils/src/crypto/node.ts"], "#async-context": ["libs/utils/src/async-context/node-async-context.ts"],