Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Imports an SDK entry, connects a direct client, calls a tool and prints the MCP client modules loaded before and after connecting: node connect-direct-client.mjs <sdkEntry>
import { registerHooks } from 'node:module';
import { pathToFileURL } from 'node:url';

const CLIENT_MODULE =
/\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/client\/|\/protocol\/dist\/(esm\/)?(clients?|lazy-client)\.m?js$/;
const loadedClientModules = [];
registerHooks({
load(url, context, nextLoad) {
if (CLIENT_MODULE.test(url)) loadedClientModules.push(url);
return nextLoad(url, context);
},
});

const [sdkEntry] = process.argv.slice(2);
const { app, connect, LogLevel, Tool, ToolContext, z } = await import(pathToFileURL(sdkEntry).href);
const beforeConnect = [...loadedClientModules];

class GreetTool extends ToolContext {
async execute({ name }) {
return `Hello, ${name}`;
}
}
Tool({ name: 'greet', description: 'Greet someone', inputSchema: { name: z.string() } })(GreetTool);

const client = await connect({
info: { name: 'esm-client', version: '1.0.0' },
apps: [app({ id: 'greetings', name: 'Greetings', tools: [GreetTool] })],
logging: { level: LogLevel.Off },
});
try {
const result = await client.callTool('greet', { name: 'FrontMCP' });
process.stdout.write(JSON.stringify({ beforeConnect, afterConnect: loadedClientModules, result }));
} finally {
await client.close();
}
15 changes: 15 additions & 0 deletions apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Imports a module and prints, as JSON, the URLs of the modules that loaded with it: node loaded-modules.mjs <entry>
import { registerHooks } from 'node:module';
import { pathToFileURL } from 'node:url';

const [entry] = process.argv.slice(2);
const loaded = [];
registerHooks({
load(url, context, nextLoad) {
loaded.push(url);
return nextLoad(url, context);
},
});

await import(pathToFileURL(entry).href);
process.stdout.write(JSON.stringify(loaded));
33 changes: 33 additions & 0 deletions apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* An ESM process loads the MCP client the first time a direct client or a remote app connects, as a CommonJS one
* does (#951): importing the SDK's ESM build loads none of it.
*
* Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`.
*/
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';

import { workspaceRoot } from './helpers/browser-bundle';

type ClientLoad = { beforeConnect: string[]; afterConnect: string[]; result: unknown };

function connectDirectClient(sdkEntry: string): ClientLoad {
const runner = resolve(__dirname, 'helpers', 'connect-direct-client.mjs');
return JSON.parse(
execFileSync(process.execPath, [runner, sdkEntry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }),
);
}

describe('the MCP client of the SDK under ESM', () => {
it('loads with the first connect(), not with the SDK', () => {
const { beforeConnect, afterConnect, result } = connectDirectClient(
resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'),
);

expect(beforeConnect).toEqual([]);
expect(afterConnect).toEqual(
expect.arrayContaining([expect.stringMatching(/@modelcontextprotocol\/sdk\/dist\/esm\/client\/index\.js$/)]),
);
expect(result).toMatchObject({ structuredContent: { value: 'Hello, FrontMCP' } });
});
});
34 changes: 34 additions & 0 deletions apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* An ESM process loads the MCP SDK's JSON Schema validator from the build its MCP server comes from, and no second
* copy from the other build (#951).
*
* Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`.
*/
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';

import { workspaceRoot } from './helpers/browser-bundle';

function modulesLoadedBy(entry: string, pattern: RegExp): string[] {
const runner = resolve(__dirname, 'helpers', 'loaded-modules.mjs');
const loaded: string[] = JSON.parse(
execFileSync(process.execPath, [runner, entry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }),
);
return loaded.filter((url) => pattern.test(url));
}

describe("the MCP server's JSON Schema validator under ESM", () => {
it('loads from the same build as the MCP server', () => {
const loaded = modulesLoadedBy(
resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'),
/\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/(server\/index|validation\/ajv-provider)\.js$/,
);

const buildsOf = (module: string) =>
loaded
.filter((url) => url.endsWith(`/${module}`))
.map((url) => url.split('/@modelcontextprotocol/sdk/dist/')[1].split('/')[0]);
expect(buildsOf('server/index.js')).toHaveLength(1);
expect(buildsOf('validation/ajv-provider.js')).toEqual(buildsOf('server/index.js'));
});
});
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default [
'@frontmcp/protocol/client',
'@frontmcp/testing',
'@frontmcp/observability',
'^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render)$',
'^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render|protocol-client)$',
],
depConstraints: [
{
Expand Down
1 change: 1 addition & 0 deletions jest.imports-mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ module.exports = {
'^#js-yaml$': path.resolve(root, 'libs/sdk/src/third-party/js-yaml.ts'),
'^#semver$': path.resolve(root, 'libs/sdk/src/third-party/semver.ts'),
'^#uipack-render$': path.resolve(root, 'libs/sdk/src/third-party/uipack-render.ts'),
'^#protocol-client$': path.resolve(root, 'libs/sdk/src/third-party/protocol-client.ts'),
};
11 changes: 9 additions & 2 deletions libs/protocol/src/__tests__/exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import { StdioServerTransport as SdkStdioServerTransport } from '@modelcontextpr
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 { AjvJsonSchemaValidator as SdkAjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';

import { Server as BrowserServer } from '../browser-mcp-server';
import {
AjvJsonSchemaValidator as BrowserAjvJsonSchemaValidator,
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 * as clients from '../clients';
import { InMemoryTransport } from '../in-memory';
import * as protocol from '../index';
import { Server as NodeServer } from '../node-mcp-server';
import { AjvJsonSchemaValidator as NodeAjvJsonSchemaValidator, 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';
Expand All @@ -26,6 +30,8 @@ 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(NodeAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator);
expect(BrowserAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator);
expect(nodeStreamableHttp.StreamableHTTPServerTransport).toBe(SdkStreamableHTTPServerTransport);
expect(nodeStreamableHttp.WebStandardStreamableHTTPServerTransport).toBe(SdkWebStandardTransport);
expect(NodeStdioServerTransport).toBe(SdkStdioServerTransport);
Expand All @@ -37,6 +43,7 @@ describe('@frontmcp/protocol exports', () => {

it('exports the protocol surface from the package root', () => {
expect(protocol.LATEST_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION);
expect(protocol.AjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator);
expect(protocol.PROTOCOL_2026_07_28).toBe('2026-07-28');
expect(protocol.StdioServerTransport).toBe(SdkStdioServerTransport);
});
Expand Down
1 change: 1 addition & 0 deletions libs/protocol/src/browser-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Re-export the real MCP Server for browser builds.
// The Server class and InMemoryTransport do not depend on Node.js streams.
export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js';
export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
2 changes: 1 addition & 1 deletion libs/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export * from './stdio';
export * from './in-memory';

// Server (browser/node conditional via #imports)
export { Server as McpServer, type ServerOptions as McpServerOptions } from '#mcp-server';
export { Server as McpServer, type ServerOptions as McpServerOptions, AjvJsonSchemaValidator } from '#mcp-server';
export {
StreamableHTTPServerTransport,
WebStandardStreamableHTTPServerTransport,
Expand Down
1 change: 1 addition & 0 deletions libs/protocol/src/node-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js';
export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
7 changes: 7 additions & 0 deletions libs/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
"types": "./src/third-party/uipack-render.ts",
"require": "./src/third-party/lazy-uipack-render.ts",
"default": "./src/third-party/uipack-render.ts"
},
"#protocol-client": {
"types": "./src/third-party/protocol-client.ts",
"worker": "./src/third-party/protocol-client.ts",
"browser": "./src/third-party/protocol-client.ts",
"import": "./src/third-party/import-protocol-client.ts",
"default": "./src/third-party/protocol-client.ts"
}
},
"type": "commonjs",
Expand Down
5 changes: 4 additions & 1 deletion libs/sdk/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
"libs/sdk/src/third-party/semver.ts",
"libs/sdk/src/third-party/lazy-semver.ts",
"libs/sdk/src/third-party/uipack-render.ts",
"libs/sdk/src/third-party/lazy-uipack-render.ts"
"libs/sdk/src/third-party/lazy-uipack-render.ts",
"libs/sdk/src/third-party/protocol-client.ts",
"libs/sdk/src/third-party/import-protocol-client.ts"
],
"assets": ["libs/sdk/README.md", "LICENSE", "libs/sdk/package.json"],
"esbuildOptions": {
Expand All @@ -36,6 +38,7 @@
"#js-yaml",
"#semver",
"#uipack-render",
"#protocol-client",
"@frontmcp/sdk",
"@frontmcp/utils",
"@frontmcp/di",
Expand Down
4 changes: 3 additions & 1 deletion libs/sdk/src/direct/direct-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import {
type ReadResourceResult,
type ServerCapabilities,
} from '@frontmcp/protocol';
import { Client } from '@frontmcp/protocol/client';
import { fileExists, pathResolve, randomUUID } from '@frontmcp/utils';

import { loadProtocolClient } from '#protocol-client';

import { listAllPages } from '../common/utils/list-all-pages.utils';
import { ELICITATION_META_KEY } from '../elicitation/elicitation-meta';
import { PublicMcpError, ToolCallError } from '../errors';
Expand Down Expand Up @@ -173,6 +174,7 @@ export class DirectClientImpl implements DirectClient {
},
};

const { Client } = await loadProtocolClient();
const mcpClient = new Client(clientInfo, clientCapabilities);

await mcpClient.connect(clientTransport);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Only a server that listens loads the Streamable HTTP transport ahead of its first session (#951). A serverless
* handler, a fetch handler and a stdio server never listen, so their first session loads it, as before.
*/
import 'reflect-metadata';

import { App, frontMcpMetadataSchema, LogLevel, Tool, ToolContext } from '../../common';
import { FrontMcpServerInstance } from '../../server/server.instance';
import { loadStreamableHttpTransport } from '../../transport/adapters/transport.streamable-http.adapter';
import { FrontMcpInstance } from '../front-mcp';

jest.mock('../../transport/adapters/transport.streamable-http.adapter', () => ({
...jest.requireActual('../../transport/adapters/transport.streamable-http.adapter'),
loadStreamableHttpTransport: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('@frontmcp/protocol', () => {
const actual = jest.requireActual('@frontmcp/protocol');
class IdleMcpServer {
setRequestHandler() {
return undefined;
}
setNotificationHandler() {
return undefined;
}
async connect() {
return undefined;
}
async close() {
return undefined;
}
}
class IdleStdioServerTransport {
async close() {
return undefined;
}
}
return { ...actual, McpServer: IdleMcpServer, StdioServerTransport: IdleStdioServerTransport };
});

const loadTransport = loadStreamableHttpTransport as jest.MockedFunction<typeof loadStreamableHttpTransport>;
const flushImmediates = () => new Promise((resolve) => setImmediate(resolve));

@Tool({ name: 'ping', inputSchema: {} })
class PingTool extends ToolContext {
async execute() {
return { pong: true };
}
}

@App({ id: 'ping', name: 'Ping', tools: [PingTool] })
class PingApp {}

const config = (name: string) => ({
info: { name, version: '1.0.0' },
apps: [PingApp],
logging: { level: LogLevel.Off, enableConsole: false },
});

describe('loading the Streamable HTTP transport ahead of the first session', () => {
const savedSentinel = process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'];

beforeEach(() => loadTransport.mockClear());

afterAll(() => {
if (savedSentinel === undefined) delete process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'];
else process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = savedSentinel;
});

it('happens after a listening server has started and reported ready', async () => {
process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = '1';
const instance = new FrontMcpInstance(frontMcpMetadataSchema.parse(config('listening')));
await instance.ready;
await flushImmediates();
expect(loadTransport).not.toHaveBeenCalled();
const start = jest.spyOn(FrontMcpServerInstance.prototype, 'start').mockResolvedValue(undefined);
const write = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await instance.start();
expect(write).toHaveBeenCalledWith(expect.stringContaining('__FRONTMCP_BOOTSTRAP_COMPLETE__'));
expect(loadTransport).not.toHaveBeenCalled();

await flushImmediates();
expect(loadTransport).toHaveBeenCalledTimes(1);
} finally {
write.mockRestore();
start.mockRestore();
}
});

it('does not happen for a serverless handler', async () => {
await FrontMcpInstance.createHandler(config('serverless'));
await flushImmediates();
expect(loadTransport).not.toHaveBeenCalled();
});

it('does not happen for a fetch handler', async () => {
const handler = await FrontMcpInstance.createFetchHandler(config('fetch'));
expect((await handler(new Request('https://example.com/healthz'))).status).toBe(200);
await flushImmediates();
expect(loadTransport).not.toHaveBeenCalled();
});

describe('for a stdio server', () => {
const savedConsole = { ...console };
const savedStdioEnv = process.env['FRONTMCP_STDIO'];
const savedLogsMax = process.env['FRONTMCP_LOGS_MAX'];
const signalsBefore = { SIGINT: process.listeners('SIGINT'), SIGTERM: process.listeners('SIGTERM') };

afterAll(() => {
Object.assign(console, savedConsole);
if (savedStdioEnv === undefined) delete process.env['FRONTMCP_STDIO'];
else process.env['FRONTMCP_STDIO'] = savedStdioEnv;
if (savedLogsMax === undefined) delete process.env['FRONTMCP_LOGS_MAX'];
else process.env['FRONTMCP_LOGS_MAX'] = savedLogsMax;
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
for (const listener of process.listeners(signal)) {
if (!signalsBefore[signal].includes(listener)) process.removeListener(signal, listener);
}
}
});

it('does not happen', async () => {
process.env['FRONTMCP_LOGS_MAX'] = '0';
await FrontMcpInstance.runStdio(config('stdio'));
await flushImmediates();
expect(loadTransport).not.toHaveBeenCalled();
});
});
});
Loading
Loading