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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
1 change: 1 addition & 0 deletions libs/protocol/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions libs/protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions libs/protocol/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -42,7 +44,8 @@
"#mcp-server",
"#server-types",
"#stdio-client",
"#stdio-server"
"#stdio-server",
"#client"
]
}
},
Expand Down Expand Up @@ -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": {
Expand All @@ -84,7 +89,8 @@
"#mcp-server",
"#server-types",
"#stdio-client",
"#stdio-server"
"#stdio-server",
"#client"
]
}
}
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions libs/protocol/src/__tests__/exports.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 38 additions & 0 deletions libs/protocol/src/__tests__/lazy-client.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion libs/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export * from './auth-types';
export * from './shared';

// MCP Client
export * from './client';
export * from '#client';

// Stdio transport
export * from './stdio';
Expand Down
19 changes: 19 additions & 0 deletions libs/protocol/src/lazy-client.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
18 changes: 17 additions & 1 deletion scripts/strip-dist-from-pkg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions scripts/strip-dist-from-pkg.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } },
Expand Down
1 change: 1 addition & 0 deletions scripts/worker-export-conditions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const EXPECT = {
'#server-types': 'node',
'#stdio-client': 'stub',
'#stdio-server': 'stub',
'#client': 'node',
},
'libs/sdk': {
'#sse-transport': 'stub',
Expand Down
1 change: 1 addition & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down