GitNexus/gitnexus/test/unit/compatible-stdio-transport.test.ts
Shockang 9d5ec5d19a
Improve MCP startup compatibility and lazy-load CLI commands (#207)
* Fix MCP startup transport compatibility

* Preserve CLI flags in MCP startup fix

* Harden MCP transport error handling

* Harden transport security and improve type safety

Transport hardening:
- Add MAX_BUFFER_SIZE (10 MB) cap to prevent OOM from oversized
  Content-Length or unbounded newline-delimited input
- Replace recursive readNewlineMessage with iterative loop to prevent
  stack overflow from consecutive empty lines
- Tighten looksLikeContentLength to require 14+ bytes before matching
- Add closed-state guard and error handling to send()
- Simplify processReadBuffer loop to break on error
- Fix loose equality (==) to strict (===)
- Widen constructor param types to ReadableStream/WritableStream

Type safety:
- Constrain createLazyAction generics so export name is validated
  against the module's actual exports at compile time
- Use proper type guard instead of lint suppression
- Fix test tsconfig type errors

Regression tests for all hardening fixes (13 tests passing).

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-03-07 07:47:09 +00:00

259 lines
8.1 KiB
TypeScript

import { PassThrough } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CompatibleStdioServerTransport } from '../../src/mcp/compatible-stdio-transport.js';
function onceMessage(transport: CompatibleStdioServerTransport): Promise<any> {
return new Promise((resolve, reject) => {
transport.onmessage = (message) => resolve(message);
transport.onerror = (error) => reject(error);
});
}
describe('CompatibleStdioServerTransport', () => {
let stdin: PassThrough;
let stdout: PassThrough;
let transport: CompatibleStdioServerTransport;
beforeEach(() => {
stdin = new PassThrough();
stdout = new PassThrough();
transport = new CompatibleStdioServerTransport(stdin, stdout);
});
it('parses Content-Length framed initialize requests', async () => {
await transport.start();
const messagePromise = onceMessage(transport);
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'codex', version: '0.1' },
},
});
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
await expect(messagePromise).resolves.toMatchObject({
method: 'initialize',
params: { clientInfo: { name: 'codex' } },
});
});
it('parses newline-delimited initialize requests', async () => {
await transport.start();
const messagePromise = onceMessage(transport);
stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'cursor', version: '0.1' },
},
})}\n`);
await expect(messagePromise).resolves.toMatchObject({
method: 'initialize',
params: { clientInfo: { name: 'cursor' } },
});
});
it('responds with Content-Length framing after Content-Length input', async () => {
await transport.start();
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'codex', version: '0.1' },
},
});
const messagePromise = onceMessage(transport);
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\n\n${body}`);
await messagePromise;
const chunks: Buffer[] = [];
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
const raw = Buffer.concat(chunks).toString('utf8');
expect(raw).toMatch(/^Content-Length: \d+\r\n\r\n/);
expect(raw).toContain('"ok":true');
});
it('reports malformed Content-Length headers once without looping forever', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
stdin.write('Content-Length:\r\n\r\n{}');
await new Promise((resolve) => setTimeout(resolve, 25));
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it('recovers after discarding a malformed Content-Length frame', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
stdin.write('Content-Length:\r\n\r\n{}');
await new Promise((resolve) => setTimeout(resolve, 25));
const body = JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'recovery-client', version: '0.1' },
},
});
const messagePromise = onceMessage(transport);
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
await expect(messagePromise).resolves.toMatchObject({
method: 'initialize',
params: { clientInfo: { name: 'recovery-client' } },
});
expect(onError).toHaveBeenCalledTimes(1);
});
// ─── Security hardening regressions ──────────────────────────────
it('rejects Content-Length values exceeding the buffer cap', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
// 20 MB — exceeds the 10 MB MAX_BUFFER_SIZE
stdin.write('Content-Length: 20971520\r\n\r\n{}');
await new Promise((resolve) => setTimeout(resolve, 25));
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]?.message).toMatch(/exceeds maximum/i);
});
it('errors when read buffer exceeds maximum size in newline mode', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
// Send a JSON-starting chunk (triggers newline mode) with no newline,
// then keep appending until we exceed the 10 MB cap
const chunkSize = 1024 * 1024; // 1 MB
const chunk = Buffer.alloc(chunkSize, 0x61); // 'a' repeated
// First byte must be '{' to trigger newline framing detection
const first = Buffer.from('{' + 'a'.repeat(chunkSize - 1));
stdin.write(first);
for (let i = 0; i < 10; i++) {
stdin.write(chunk);
}
await new Promise((resolve) => setTimeout(resolve, 25));
expect(onError).toHaveBeenCalled();
const hasMaxSizeError = onError.mock.calls.some(
(call) => call[0] instanceof Error && /maximum size/i.test(call[0].message),
);
expect(hasMaxSizeError).toBe(true);
});
it('handles many consecutive empty lines without stack overflow', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
// First, seed the framing mode with a valid newline-delimited message
const seed = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'seed', version: '0.1' },
},
});
const seedPromise = onceMessage(transport);
stdin.write(seed + '\n');
await seedPromise;
// Now send 15K empty lines followed by a real message — this would
// stack-overflow with the old recursive readNewlineMessage
const followup = JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'notifications/initialized',
params: {},
});
const messagePromise = onceMessage(transport);
stdin.write('\n'.repeat(15_000) + followup + '\n');
await expect(messagePromise).resolves.toMatchObject({
method: 'notifications/initialized',
});
expect(onError).not.toHaveBeenCalled();
});
it('rejects send() after transport is closed', async () => {
await transport.start();
await transport.close();
await expect(
transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
).rejects.toThrow(/closed/i);
});
it('does not detect content-length framing from short ambiguous prefix', async () => {
await transport.start();
const onError = vi.fn();
transport.onerror = onError;
// Write only "cont" — fewer than 14 bytes, should NOT trigger
// content-length detection. Transport should wait for more data.
stdin.write(Buffer.from('cont'));
await new Promise((resolve) => setTimeout(resolve, 25));
// No message and no error — transport is waiting for more data
expect(onError).not.toHaveBeenCalled();
});
it('responds with newline framing after newline input', async () => {
await transport.start();
const messagePromise = onceMessage(transport);
stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'cursor', version: '0.1' },
},
})}\n`);
await messagePromise;
const chunks: Buffer[] = [];
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
const raw = Buffer.concat(chunks).toString('utf8');
expect(raw).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n');
});
});