fix: add HTTP fetch compatibility mode
This commit is contained in:
parent
2171c1f209
commit
3e06e582ef
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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] } : {}),
|
||||
|
||||
170
src/runtime/node-http-fetch.ts
Normal file
170
src/runtime/node-http-fetch.ts
Normal 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.');
|
||||
}
|
||||
@ -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(() => {});
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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' },
|
||||
|
||||
3
tests/fixtures/imports/.cursor/mcp.json
vendored
3
tests/fixtures/imports/.cursor/mcp.json
vendored
@ -1,7 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shared": {
|
||||
"baseUrl": "https://cursor.local/mcp"
|
||||
"baseUrl": "https://cursor.local/mcp",
|
||||
"httpFetch": "node-http1"
|
||||
},
|
||||
"cursor-only": {
|
||||
"command": "cursor-cli",
|
||||
|
||||
149
tests/node-http-fetch.test.ts
Normal file
149
tests/node-http-fetch.test.ts
Normal 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()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user