Skip to content
Open
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
62 changes: 53 additions & 9 deletions src/custom-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,18 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
private clientName: string = 'unknown';
private disableNotifications: boolean = false;

// Serialised write queue for notification/progress/custom writes.
// Ensures at most one `once('drain', ...)` listener is registered at a time,
// preventing the MaxListenersExceededWarning on process.stdout (#391).
private stdoutQueue: string[] = [];
private stdoutDraining: boolean = false;

constructor() {
super();

// Allow enough listeners for concurrent MCP response drain waits during
// heavy tool usage, without triggering false-positive MaxListeners warnings.
process.stdout.setMaxListeners(50);

// Store original methods
this.originalConsole = {
Expand Down Expand Up @@ -123,6 +133,39 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
return this.messageBuffer.length;
}

/**
* Write a serialised line to stdout via the notification write queue.
* At most one `once('drain', ...)` listener is registered at any time,
* preventing MaxListenersExceededWarning during heavy notification load.
*/
private writeNotificationToStdout(data: string): void {
this.stdoutQueue.push(data);
if (!this.stdoutDraining) {
this.flushNotificationQueue();
}
}

private flushNotificationQueue(): void {
while (this.stdoutQueue.length > 0) {
const data = this.stdoutQueue[0];
const flushed = this.originalStdoutWrite.call(process.stdout, data);
if (flushed) {
this.stdoutQueue.shift();
} else {
// stdout buffer is full — wait for drain before writing more.
// Using once() ensures the listener is removed immediately after firing.
if (!this.stdoutDraining) {
this.stdoutDraining = true;
process.stdout.once('drain', () => {
this.stdoutDraining = false;
this.flushNotificationQueue();
});
}
return;
}
}
}

private setupConsoleRedirection() {
console.log = (...args: any[]) => {
if (this.isInitialized) {
Expand Down Expand Up @@ -258,8 +301,9 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
}
};

// Send as valid JSON-RPC notification
this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
// Route through the serialised write queue to avoid accumulating
// drain listeners on process.stdout during heavy notification load.
this.writeNotificationToStdout(JSON.stringify(notification) + '\n');
} catch (error) {
// Fallback to a simple JSON-RPC error notification if JSON serialization fails
const fallbackNotification = {
Expand All @@ -271,7 +315,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
data: `Log serialization failed: ${args.join(' ')}`
}
};
this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n');
}
}

Expand Down Expand Up @@ -307,7 +351,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
}
};

this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
this.writeNotificationToStdout(JSON.stringify(notification) + '\n');
} catch (error) {
// Fallback to basic JSON-RPC notification
const fallbackNotification = {
Expand All @@ -319,7 +363,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
data: `sendLog failed: ${message}`
}
};
this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n');
}
}

Expand All @@ -343,7 +387,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
}
};

this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
this.writeNotificationToStdout(JSON.stringify(notification) + '\n');
} catch (error) {
// Fallback to basic JSON-RPC notification for progress
const fallbackNotification = {
Expand All @@ -355,7 +399,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
data: `Progress ${token}: ${value}${total ? `/${total}` : ''}`
}
};
this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n');
}
}

Expand All @@ -375,7 +419,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
params: params
};

this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
this.writeNotificationToStdout(JSON.stringify(notification) + '\n');
} catch (error) {
// Fallback to basic JSON-RPC notification for custom notifications
const fallbackNotification = {
Expand All @@ -387,7 +431,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport {
data: `Custom notification failed: ${method}: ${JSON.stringify(params)}`
}
};
this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n');
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,36 @@ export async function validatePath(requestedPath: string): Promise<string> {
});
throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
}

// SECURITY FIX: When the full path doesn't exist (e.g., writing a new file),
// resolve the parent directory to detect symlinks in the path chain.
// Without this, an attacker could create a symlink inside an allowed directory
// pointing to a restricted location, then write to a non-existent file through
// that symlink — bypassing the directory restriction check.
try {
const parentDir = path.dirname(absoluteOriginal);
const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' });
const basename = path.basename(absoluteOriginal);
resolvedRealPath = path.join(resolvedParent, basename);
} catch {
// Parent also doesn't exist — walk up the tree to find
// the deepest existing ancestor and resolve it
let current = absoluteOriginal;
let remaining: string[] = [];
while (true) {
const parent = path.dirname(current);
if (parent === current) break; // reached filesystem root
remaining.unshift(path.basename(current));
current = parent;
try {
const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' });
resolvedRealPath = path.join(resolvedAncestor, ...remaining);
break;
} catch {
// keep walking up
}
}
}
}

const pathForNextCheck = resolvedRealPath ?? absoluteOriginal;
Expand Down