fix: add HTTP fetch compatibility mode

This commit is contained in:
Peter Steinberger 2026-05-14 17:31:36 +01:00
parent 2171c1f209
commit 3e06e582ef
No known key found for this signature in database
19 changed files with 492 additions and 1 deletions

View File

@ -4,6 +4,7 @@
### Config
- Add `httpFetch: "node-http1"` for HTTP MCP servers whose providers reject Node's built-in `fetch`, and auto-apply it to Sunsama's endpoint. (Issue #158, thanks @mattash)
- Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas)
- Add `mcporter vault set <server>` and `mcporter vault clear <server>` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156)

View File

@ -166,6 +166,24 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
- `--env KEY=VAL` entries merge with existing `env` dictionaries if you later persist the same server; nothing is lost when you alternate between CLI flags and JSON edits.
- `--header KEY=VAL` entries merge into the persisted HTTP `headers` object when used with `--persist`; values support the same `$env:VAR`, `${VAR}`, and `${VAR:-fallback}` placeholders as config-file headers.
## HTTP Compatibility
HTTP MCP servers normally use Node's built-in `fetch` through the upstream MCP SDK. If a provider rejects that stack but accepts plain Node `https.request` traffic, set `httpFetch: "node-http1"` on the server entry to force an HTTP/1.1 fetch implementation for Streamable HTTP and SSE POST requests:
```jsonc
{
"mcpServers": {
"sunsama": {
"baseUrl": "https://api.sunsama.com/mcp",
"headers": { "Authorization": "Bearer ${SUNSAMA_TOKEN}" },
"httpFetch": "node-http1",
},
},
}
```
The Sunsama endpoint is auto-detected and uses this compatibility path by default.
## JSON Schema for IDE Support
mcporter provides a JSON Schema for config file validation and autocompletion. Add the `$schema` property to your config file:

View File

@ -197,6 +197,16 @@
"description": "Environment variable name containing the bearer token (snake_case)",
"type": "string"
},
"httpFetch": {
"description": "HTTP fetch implementation for Streamable HTTP/SSE requests",
"type": "string",
"enum": ["default", "node-http1"]
},
"http_fetch": {
"description": "HTTP fetch implementation for Streamable HTTP/SSE requests",
"type": "string",
"enum": ["default", "node-http1"]
},
"lifecycle": {
"anyOf": [
{

View File

@ -28,6 +28,7 @@ export interface SerializedServerDefinition {
readonly oauthTokenEndpointAuthMethod?: string;
readonly oauthRedirectUrl?: string;
readonly oauthScope?: string;
readonly httpFetch?: ServerDefinition['httpFetch'];
readonly allowedTools?: readonly string[];
readonly blockedTools?: readonly string[];
}
@ -151,6 +152,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};
@ -173,6 +175,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};

View File

@ -14,6 +14,7 @@ export type SerializedServerDefinition = {
oauthTokenEndpointAuthMethod?: string;
oauthRedirectUrl?: string;
oauthScope?: string;
httpFetch?: ServerDefinition['httpFetch'];
allowedTools?: readonly string[];
blockedTools?: readonly string[];
env?: Record<string, string>;
@ -40,6 +41,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,
@ -60,6 +62,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,

View File

@ -205,6 +205,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
);
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined;
const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined;
const httpFetch = normalizeHttpFetch(stringFromAliases(record, 'httpFetch', 'http_fetch'));
const headers = toStringRecord((def as Record<string, unknown>).headers);
const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
const rawLifecycle = getRawLifecycle(record.lifecycle);
@ -228,6 +229,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
oauthRedirectUrl,
oauthScope,
oauthCommand,
httpFetch,
lifecycle: resolveLifecycle(name, rawLifecycle, command),
logging,
...(allowedTools !== undefined ? { allowedTools } : {}),
@ -382,6 +384,10 @@ function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | und
return args ? { args } : undefined;
}
function normalizeHttpFetch(value: string | undefined): ServerDefinition['httpFetch'] | undefined {
return value === 'default' || value === 'node-http1' ? value : undefined;
}
function stringFromAliases(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];

View File

@ -25,6 +25,7 @@ export function normalizeServerEntry(
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
const httpFetch = normalizeHttpFetch(raw.httpFetch ?? raw.http_fetch);
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
const oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
const headers = buildHeaders(raw);
@ -76,6 +77,7 @@ export function normalizeServerEntry(
oauthRedirectUrl,
oauthScope,
oauthCommand: defaultedOauthCommand,
httpFetch,
source,
sources,
lifecycle,
@ -146,6 +148,10 @@ function normalizeAuth(auth: string | undefined): string | undefined {
return undefined;
}
function normalizeHttpFetch(value: 'default' | 'node-http1' | undefined): 'default' | 'node-http1' | undefined {
return value;
}
function normalizePath(input: string | undefined): string | undefined {
if (!input) {
return undefined;

View File

@ -48,6 +48,10 @@ const RawLoggingSchema = z
.optional()
.describe('Logging configuration for the server');
const RawHttpFetchSchema = z
.enum(['default', 'node-http1'])
.describe('HTTP fetch implementation for Streamable HTTP/SSE requests');
export const RawEntrySchema = z
.object({
description: z.string().optional().describe('Human-readable description of the server'),
@ -118,6 +122,8 @@ export const RawEntrySchema = z
.string()
.optional()
.describe('Environment variable name containing the bearer token (snake_case)'),
httpFetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
http_fetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
lifecycle: RawLifecycleSchema.optional(),
logging: RawLoggingSchema,
allowedTools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (camelCase)'),
@ -204,6 +210,7 @@ export interface ServerDefinition {
readonly oauthCommand?: {
readonly args: string[];
};
readonly httpFetch?: 'default' | 'node-http1';
readonly source?: ServerSource;
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;

View File

@ -153,6 +153,11 @@ function convertExternalEntry(value: Record<string, unknown>): RawEntry | null {
result.oauthTokenEndpointAuthMethod = oauthTokenEndpointAuthMethod;
}
const httpFetch = asString(value.httpFetch ?? value.http_fetch);
if (httpFetch) {
result.httpFetch = httpFetch;
}
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
if (url) {
result.baseUrl = url;

View File

@ -80,6 +80,7 @@ function serializeRawEntry(server: ServerDefinition): RawEntry {
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),
...(server.httpFetch ? { httpFetch: server.httpFetch } : {}),
...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}),
...(server.logging ? { logging: server.logging } : {}),
...(server.allowedTools ? { allowedTools: [...server.allowedTools] } : {}),

View File

@ -0,0 +1,170 @@
import http from 'node:http';
import https from 'node:https';
import { Buffer } from 'node:buffer';
import { Readable } from 'node:stream';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
const MAX_REDIRECTS = 20;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export const nodeHttp1Fetch: FetchLike = async (input, init = {}) => {
return nodeHttp1FetchWithRedirects(input, init, 0);
};
async function nodeHttp1FetchWithRedirects(
input: string | URL,
init: RequestInit,
redirectCount: number
): Promise<Response> {
const url = input instanceof URL ? input : new URL(input);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new TypeError(`node-http1 fetch only supports http: and https: URLs, got ${url.protocol}`);
}
if (init.signal?.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
const headers = normalizeRequestHeaders(init.headers);
const body = await materializeRequestBody(init.body);
if (body !== undefined && !hasHeader(headers, 'content-length') && !hasHeader(headers, 'transfer-encoding')) {
headers['content-length'] = String(Buffer.byteLength(body));
}
return new Promise<Response>((resolve, reject) => {
const client = url.protocol === 'https:' ? https : http;
const request = client.request(
url,
{
method: init.method ?? 'GET',
headers,
},
(response) => {
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(response.headers)) {
if (Array.isArray(value)) {
for (const item of value) {
responseHeaders.append(key, item);
}
} else if (value !== undefined) {
responseHeaders.set(key, String(value));
}
}
const status = response.statusCode ?? 502;
const location = responseHeaders.get('location');
if (REDIRECT_STATUSES.has(status) && location && init.redirect !== 'manual') {
response.resume();
if (init.redirect === 'error') {
reject(new TypeError(`Redirect encountered for ${url.href}`));
return;
}
if (redirectCount >= MAX_REDIRECTS) {
reject(new TypeError(`Too many redirects while fetching ${url.href}`));
return;
}
let nextUrl: URL;
try {
nextUrl = new URL(location, url);
} catch (error) {
reject(error);
return;
}
resolve(
nodeHttp1FetchWithRedirects(nextUrl, buildRedirectInit(init, status, url, nextUrl), redirectCount + 1)
);
return;
}
if (NULL_BODY_STATUSES.has(status)) {
response.resume();
}
resolve(
new Response(
NULL_BODY_STATUSES.has(status) ? null : (Readable.toWeb(response) as unknown as ReadableStream),
{
status,
statusText: response.statusMessage,
headers: responseHeaders,
}
)
);
}
);
const abort = () => {
request.destroy(new DOMException('The operation was aborted.', 'AbortError'));
};
init.signal?.addEventListener('abort', abort, { once: true });
request.once('close', () => init.signal?.removeEventListener('abort', abort));
request.once('error', reject);
if (body !== undefined) {
request.write(body);
}
request.end();
});
}
function buildRedirectInit(init: RequestInit, status: number, currentUrl: URL, nextUrl: URL): RequestInit {
const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers);
if (currentUrl.origin !== nextUrl.origin) {
stripCrossOriginRedirectHeaders(headers);
}
if ((status === 301 || status === 302 || status === 303) && method !== 'GET' && method !== 'HEAD') {
headers.delete('content-length');
headers.delete('content-type');
return {
...init,
method: 'GET',
body: null,
headers,
};
}
return {
...init,
headers,
};
}
function stripCrossOriginRedirectHeaders(headers: Headers): void {
headers.delete('authorization');
headers.delete('cookie');
headers.delete('proxy-authorization');
}
function normalizeRequestHeaders(headers: HeadersInit | undefined): Record<string, string> {
const normalized: Record<string, string> = {};
if (!headers) {
return normalized;
}
new Headers(headers).forEach((value, key) => {
normalized[key] = value;
});
return normalized;
}
function hasHeader(headers: Record<string, string>, name: string): boolean {
const lower = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === lower);
}
async function materializeRequestBody(body: RequestInit['body']): Promise<Buffer | string | undefined> {
if (body == null) {
return undefined;
}
if (typeof body === 'string') {
return body;
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (body instanceof Blob) {
return Buffer.from(await body.arrayBuffer());
}
if (body instanceof ArrayBuffer) {
return Buffer.from(body);
}
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
throw new TypeError('node-http1 fetch does not support streaming request bodies.');
}

View File

@ -13,6 +13,7 @@ import { readCachedAccessToken } from '../oauth-persistence.js';
import { materializeHeaders } from '../runtime-header-utils.js';
import { isUnauthorizedError, maybeEnableOAuth } from '../runtime-oauth-support.js';
import { closeTransportAndWait } from '../runtime-process-utils.js';
import { nodeHttp1Fetch } from './node-http-fetch.js';
import {
connectWithAuth,
isOAuthFlowError,
@ -58,6 +59,7 @@ function isLegacySseTransportMismatch(error: unknown): boolean {
interface ResolvedHttpTransportOptions {
requestInit?: RequestInit;
authProvider?: OAuthSession['provider'];
fetch?: typeof nodeHttp1Fetch;
}
type HttpClientContextAttempt =
@ -110,9 +112,26 @@ function createHttpTransportOptions(
return {
requestInit: effectiveHeaders ? { headers: effectiveHeaders as HeadersInit } : undefined,
authProvider: oauthSession?.provider,
fetch: resolveHttpFetchOverride(definition),
};
}
function resolveHttpFetchOverride(definition: ServerDefinition): typeof nodeHttp1Fetch | undefined {
if (definition.command.kind !== 'http') {
return undefined;
}
if (definition.httpFetch === 'default') {
return undefined;
}
if (definition.httpFetch === 'node-http1') {
return nodeHttp1Fetch;
}
if (definition.command.url.hostname.toLowerCase() === 'api.sunsama.com') {
return nodeHttp1Fetch;
}
return undefined;
}
async function closeOAuthSession(oauthSession?: OAuthSession): Promise<void> {
await oauthSession?.close().catch(() => {});
}

View File

@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { parseGenerateFlags } from '../src/cli/generate/flags.js';
import { inferNameFromCommand } from '../src/cli/generate/name-utils.js';
import { normalizeDefinition } from '../src/cli/generate/definition.js';
import { buildGenerateCliCommand } from '../src/cli/generate/template-data.js';
import { serializeDefinition } from '../src/cli-metadata.js';
import type { SerializedServerDefinition } from '../src/cli-metadata.js';
describe('generate-cli runner internals', () => {
@ -80,6 +82,17 @@ describe('generate-cli runner internals', () => {
expect(inferred).toBe('shadcn');
});
it('preserves HTTP fetch compatibility metadata in generated CLI definitions', () => {
const definition = normalizeDefinition({
name: 'sunsama',
command: 'https://api.sunsama.com/mcp',
httpFetch: 'node-http1',
});
expect(definition.httpFetch).toBe('node-http1');
expect(serializeDefinition(definition).httpFetch).toBe('node-http1');
});
it('wraps single-token stdio commands when passed via --command', () => {
const args = ['--command', './scripts/mcp-server.ts'];
const parsed = parseGenerateFlags([...args]);

View File

@ -101,6 +101,7 @@ describe('config imports', () => {
expect(shared?.command.kind === 'http' ? shared.command.url.toString() : undefined).toBe(
'https://cursor.local/mcp'
);
expect(shared?.httpFetch).toBe('node-http1');
expect(shared?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.cursor', 'mcp.json'),

View File

@ -125,6 +125,40 @@ describe('config normalization', () => {
expect(snake?.oauthScope).toBe('email');
});
it('normalizes HTTP fetch compatibility options', async () => {
await fs.mkdir(TEMP_DIR, { recursive: true });
const configPath = path.join(TEMP_DIR, 'mcporter-http-fetch.json');
await fs.writeFile(
configPath,
JSON.stringify(
{
mcpServers: {
camel: {
baseUrl: 'https://api.sunsama.com/mcp',
httpFetch: 'node-http1',
},
snake: {
baseUrl: 'https://example.com/mcp',
http_fetch: 'node-http1',
},
defaulted: {
baseUrl: 'https://example.com/mcp',
httpFetch: 'default',
},
},
},
null,
2
),
'utf8'
);
const servers = await loadServerDefinitions({ configPath });
expect(servers.find((entry) => entry.name === 'camel')?.httpFetch).toBe('node-http1');
expect(servers.find((entry) => entry.name === 'snake')?.httpFetch).toBe('node-http1');
expect(servers.find((entry) => entry.name === 'defaulted')?.httpFetch).toBe('default');
});
it('normalizes pre-registered OAuth client fields', async () => {
await fs.mkdir(TEMP_DIR, { recursive: true });
const configPath = path.join(TEMP_DIR, 'mcporter-oauth-client.json');

View File

@ -22,6 +22,7 @@ describe('config render helpers', () => {
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
httpFetch: 'node-http1',
allowedTools: ['read'],
env: { FOO: 'bar' },
};
@ -40,6 +41,7 @@ describe('config render helpers', () => {
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
httpFetch: 'node-http1',
allowedTools: ['read'],
env: { FOO: 'bar' },
source: { kind: 'import', path: '/tmp/source.json' },

View File

@ -1,7 +1,8 @@
{
"mcpServers": {
"shared": {
"baseUrl": "https://cursor.local/mcp"
"baseUrl": "https://cursor.local/mcp",
"httpFetch": "node-http1"
},
"cursor-only": {
"command": "cursor-cli",

View File

@ -0,0 +1,149 @@
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { nodeHttp1Fetch } from '../src/runtime/node-http-fetch.js';
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => {
await cleanup?.();
cleanup = undefined;
});
describe('nodeHttp1Fetch', () => {
it('follows redirects by default', async () => {
const { baseUrl, close } = await serve((request, response) => {
if (request.url === '/start') {
response.writeHead(302, { location: '/final' });
response.end();
return;
}
response.writeHead(200, { 'content-type': 'text/plain' });
response.end(`ok:${request.method}:${request.url}`);
});
cleanup = close;
const response = await nodeHttp1Fetch(new URL('/start', baseUrl));
expect(response.status).toBe(200);
expect(await response.text()).toBe('ok:GET:/final');
});
it('preserves method and body for 307 redirects', async () => {
const { baseUrl, close } = await serve((request, response) => {
if (request.url === '/start') {
response.writeHead(307, { location: '/target' });
response.end();
return;
}
let body = '';
request.setEncoding('utf8');
request.on('data', (chunk: string) => {
body += chunk;
});
request.on('end', () => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ method: request.method, body }));
});
});
cleanup = close;
const response = await nodeHttp1Fetch(new URL('/start', baseUrl), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ok: true }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ method: 'POST', body: '{"ok":true}' });
});
it('honors manual redirect mode', async () => {
const { baseUrl, close } = await serve((_request, response) => {
response.writeHead(302, { location: '/final' });
response.end();
});
cleanup = close;
const response = await nodeHttp1Fetch(new URL('/start', baseUrl), { redirect: 'manual' });
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/final');
await response.body?.cancel();
});
it('rejects malformed redirect locations', async () => {
const { baseUrl, close } = await serve((_request, response) => {
response.writeHead(302, { location: 'http://[' });
response.end();
});
cleanup = close;
await expect(nodeHttp1Fetch(new URL('/start', baseUrl))).rejects.toThrow();
});
it('strips sensitive request headers on cross-origin redirects', async () => {
let redirectedHeaders: IncomingMessage['headers'] | undefined;
const target = await serve((request, response) => {
redirectedHeaders = request.headers;
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
});
const source = await serve((_request, response) => {
response.writeHead(302, { location: new URL('/target', target.baseUrl).href });
response.end();
});
cleanup = async () => {
await source.close();
await target.close();
};
const response = await nodeHttp1Fetch(new URL('/start', source.baseUrl), {
headers: {
Authorization: 'Bearer secret',
Cookie: 'sid=secret',
'Proxy-Authorization': 'Basic secret',
'X-Keep': 'ok',
},
});
expect(response.status).toBe(200);
await response.body?.cancel();
expect(redirectedHeaders?.authorization).toBeUndefined();
expect(redirectedHeaders?.cookie).toBeUndefined();
expect(redirectedHeaders?.['proxy-authorization']).toBeUndefined();
expect(redirectedHeaders?.['x-keep']).toBe('ok');
});
it('handles null-body response statuses', async () => {
const { baseUrl, close } = await serve((_request, response) => {
response.writeHead(204);
response.end();
});
cleanup = close;
const response = await nodeHttp1Fetch(new URL('/empty', baseUrl));
expect(response.status).toBe(204);
expect(response.body).toBeNull();
});
});
type HttpHandler = (request: IncomingMessage, response: ServerResponse) => void;
async function serve(handler: HttpHandler): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
const server = createServer(handler);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Expected TCP test server address.');
}
return {
baseUrl: new URL(`http://127.0.0.1:${address.port}`),
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}

View File

@ -211,6 +211,48 @@ describe('createClientContext (HTTP)', () => {
expect(mocks.readCachedAccessToken).toHaveBeenCalledWith(definition, logger);
});
it('uses the HTTP/1.1 fetch compatibility path when configured', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://example.com/mcp'),
httpFetch: 'node-http1',
};
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
expect(fetchOverride).toEqual(expect.any(Function));
});
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
});
it('uses the HTTP/1.1 fetch compatibility path for Sunsama by default', async () => {
const definition = stubHttpDefinition('https://api.sunsama.com/mcp');
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
expect(fetchOverride).toEqual(expect.any(Function));
});
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
});
it('honors explicit default fetch mode for Sunsama', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://api.sunsama.com/mcp'),
httpFetch: 'default',
};
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
const fetchOverride = (transport as { _fetch?: unknown })._fetch;
expect(fetchOverride).toBeUndefined();
});
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
});
it('promotes ad-hoc HTTP servers after generic 401 errors from Streamable HTTP', async () => {
const definition = stubHttpDefinition('https://example.com/secure');