Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/replace-cross-spawn-with-tinyexec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@modelcontextprotocol/client': patch
---

`StdioClientTransport` now spawns with [`tinyexec`](https://github.com/tinylibs/tinyexec) instead
of `cross-spawn`, cutting six runtime dependencies down to one with no transitive deps. `tinyexec`
vendors cross-spawn's command normalization, so Windows `.cmd`/`.bat` handling is unchanged, and
its `process.env` merging and `node_modules/.bin` PATH injection are both disabled so the
{@linkcode getDefaultEnvironment} safelist and command resolution stay exactly as before.

No public API change.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ When modifying exports:
- Use explicit named exports, not `export *`, in package `index.ts` files and `core-internal/public`.
- Adding a symbol to a package `index.ts` makes it public API — do so intentionally.
- Internal helpers should stay in the core internal barrel and not be added to `core-internal/public` or package index files.
- The package root entry must stay runtime-neutral so browser and Cloudflare Workers bundlers can consume it. Exports whose module graph transitively touches unpolyfillable Node builtins (`node:child_process`, `node:net`, `cross-spawn`, etc.) must live at a named subpath export (e.g. `./stdio`) and be covered by a `barrelClean` test in that package.
- The package root entry must stay runtime-neutral so browser and Cloudflare Workers bundlers can consume it. Exports whose module graph transitively touches unpolyfillable Node builtins (`node:child_process`, `node:net`, `tinyexec`, etc.) must live at a named subpath export (e.g. `./stdio`) and be covered by a `barrelClean` test in that package.

### Transport System

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
"@modelcontextprotocol/server": "workspace:^",
"@types/content-type": "catalog:devTools",
"@types/cors": "catalog:devTools",
"@types/cross-spawn": "catalog:devTools",
"@types/eventsource": "catalog:devTools",
"@types/express": "catalog:devTools",
"@types/express-serve-static-core": "catalog:devTools",
Expand Down
3 changes: 1 addition & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,11 @@
},
"dependencies": {
"@modelcontextprotocol/core": "workspace:*",
"cross-spawn": "catalog:runtimeClientOnly",
"eventsource": "catalog:runtimeClientOnly",
"eventsource-parser": "catalog:runtimeClientOnly",
"jose": "catalog:runtimeClientOnly",
"pkce-challenge": "catalog:runtimeShared",
"tinyexec": "catalog:runtimeClientOnly",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
Expand All @@ -151,7 +151,6 @@
"ajv": "catalog:runtimeShared",
"ajv-formats": "catalog:runtimeShared",
"@types/content-type": "catalog:devTools",
"@types/cross-spawn": "catalog:devTools",
"@types/eventsource": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"@eslint/js": "catalog:devTools",
Expand Down
53 changes: 41 additions & 12 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PassThrough } from 'node:stream';

import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core-internal';
import spawn from 'cross-spawn';
import { x } from 'tinyexec';

export type StdioServerParameters = {
/**
Expand Down Expand Up @@ -93,6 +93,22 @@ export function getDefaultEnvironment(): Record<string, string> {
return env;
}

/**
* `tinyexec` always merges `process.env` into the child environment, so passing an allowlist
* alone would not keep parent variables out. Masking every parent key with `undefined` cancels
* that merge, because Node's `spawn` drops `undefined` entries — leaving only the keys the
* caller (via {@linkcode getDefaultEnvironment} and `StdioServerParameters.env`) opted into.
*/
function maskInheritedEnvironment(): Record<string, undefined> {
const mask: Record<string, undefined> = {};

for (const key of Object.keys(process.env)) {
mask[key] = undefined;
}

return mask;
}

/**
* Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout.
*
Expand Down Expand Up @@ -127,17 +143,30 @@ export class StdioClientTransport implements Transport {
}

return new Promise((resolve, reject) => {
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
// merge default env with server env because mcp server needs some env vars
env: {
...getDefaultEnvironment(),
...this._serverParams.env
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
windowsHide: process.platform === 'win32',
cwd: this._serverParams.cwd
});
const child = x(this._serverParams.command, this._serverParams.args ?? [], {
// Leave PATH exactly as given: tinyexec otherwise prepends every ancestor
// `node_modules/.bin` directory, which would change how the server command resolves.
nodePath: false,
nodeOptions: {
// merge default env with server env because mcp server needs some env vars
env: {
...maskInheritedEnvironment(),
...getDefaultEnvironment(),
...this._serverParams.env
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
windowsHide: process.platform === 'win32',
cwd: this._serverParams.cwd
}
}).process;

if (!child) {
reject(new SdkError(SdkErrorCode.NotConnected, 'Failed to spawn server process'));
return;
}

this._process = child;

this._process.on('error', error => {
reject(error);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export type { SSEClientTransportOptions } from './client/sse';
export { SSEClientTransport, SseError } from './client/sse';
export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation';
// StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from
// the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn).
// the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, tinyexec).
export type {
ReconnectionScheduler,
StartSSEOptions,
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/stdio.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Subpath entry for the stdio client transport.
//
// Exported separately from the root entry so that bundling `@modelcontextprotocol/client` for browser or
// Cloudflare Workers targets does not pull in `node:child_process`, `node:stream`, or `cross-spawn`. Import
// Cloudflare Workers targets does not pull in `node:child_process`, `node:stream`, or `tinyexec`. Import
// from `@modelcontextprotocol/client/stdio` only in process-spawning runtimes (Node.js, Bun, Deno).

export type { StdioServerParameters } from './client/stdio';
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/client/barrelClean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ensureBuilt } from '../helpers/ensureBuilt';
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..');
const distDir = join(pkgDir, 'dist');
const requireDist = createRequire(join(pkgDir, 'package.json'));
const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/;
const NODE_ONLY = /\b(child_process|tinyexec|node:stream|node:child_process)\b/;
// Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match.
const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m;
const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft'];
Expand Down
205 changes: 0 additions & 205 deletions packages/client/test/client/crossSpawn.test.ts

This file was deleted.

Loading
Loading