feat: support ad-hoc HTTP headers

This commit is contained in:
Peter Steinberger 2026-05-04 05:49:25 +01:00
parent 45dcb6561e
commit 3f4f8dc317
No known key found for this signature in database
10 changed files with 78 additions and 15 deletions

View File

@ -11,6 +11,7 @@
- Quote generated `emit-ts` members for tool names that are not valid TypeScript identifiers. (PR #149 / issue #30, thanks @solomonneas)
- Resolve relative stdio args in generated CLI bundles against the generated script location instead of the caller's current directory. (PR #148 / issue #56, thanks @solomonneas)
- Print OAuth manual-completion URLs at the default warning log level so headless users can copy them. (PR #143 / issue #139, thanks @stainlu)
- Support repeatable `--header KEY=value` flags for ad-hoc HTTP servers and persisted ad-hoc entries. (Issue #117)
### Config

View File

@ -33,7 +33,7 @@ Notice that the second command repeats the URL. Ad-hoc definitions are ephemeral
## Transport Detection
- **HTTP(S)**: Providing a URL defaults to the streamable HTTP transport. `https://` works out of the box; `http://` requires `--allow-http` (or the hidden alias `--insecure`) to acknowledge cleartext traffic. The `--sse` flag is a hidden alias for `--http-url` to match older examples.
- **HTTP(S)**: Providing a URL defaults to the streamable HTTP transport. `https://` works out of the box; `http://` requires `--allow-http` (or the hidden alias `--insecure`) to acknowledge cleartext traffic. The `--sse` flag is a hidden alias for `--http-url` to match older examples. Use repeatable `--header KEY=value` flags for private servers that require headers such as `Authorization`, `X-API-Key`, or tenant selectors.
- **STDIO**: Supplying `--stdio` (with a command string) or `--stdio-bin` (binary + args) selects the stdio transport. Your current shell environment is inherited automatically; use `--env KEY=value` only when you need to inject/override specific variables (and `--cwd` to change directories).
- **Conflict guard**: Passing both URL and stdio flags errors out so we dont guess.
@ -62,7 +62,7 @@ The CLI still avoids surprise prompts during `mcporter list`; the upgrade happen
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.
- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file.
- Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline.
- Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline. Ad-hoc HTTP headers are persisted with the entry, so placeholders such as `--header 'Authorization=$env:MY_TOKEN'` keep working through the normal config header resolver.
## Safety Nets

View File

@ -145,11 +145,12 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
## Ad-hoc & Persistence
- `--http-url` and `--stdio` flags live on `mcporter list|call|auth`, keeping `mcporter config` focused on persistent config files.
- `--http-url` and `--stdio` flags live on `mcporter list|call|auth`, keeping `mcporter config` focused on persistent config files. Ad-hoc HTTP targets also accept repeatable `--header KEY=value` flags for private endpoints.
- Names default to slugified hostnames or executable/script combos. Supply `--name` to improve reuse; mcporter uses that slug for OAuth caches even before persistence.
- `--allow-http` is mandatory for cleartext endpoints so we never downgrade transport silently.
- Add `--persist <path>` (defaulting to `config/mcporter.json` when omitted) to copy the ad-hoc definition into config. We reuse the same serializer as the import pipeline, so copying from Cursor → local config produces identical structure and preserves custom env/header fields.
- `--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.
## JSON Schema for IDE Support

View File

@ -13,6 +13,7 @@ export interface EphemeralServerSpec {
stdioArgs?: string[];
cwd?: string;
env?: Record<string, string>;
headers?: Record<string, string>;
description?: string;
persistPath?: string;
}
@ -44,7 +45,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
const command: CommandSpec = {
kind: 'http',
url,
headers: __configInternals.ensureHttpAcceptHeader(undefined),
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
};
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
@ -61,6 +62,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
baseUrl: url.href,
...(spec.description ? { description: spec.description } : {}),
...(spec.env && Object.keys(spec.env).length > 0 ? { env: spec.env } : {}),
...(spec.headers && Object.keys(spec.headers).length > 0 ? { headers: spec.headers } : {}),
...(lifecycle ? { lifecycle: serializeLifecycle(lifecycle) } : {}),
};
return { definition, name, persistedEntry };

View File

@ -125,6 +125,7 @@ export function printAuthHelp(): void {
'Ad-hoc targets:',
' --http-url <url> Register an HTTP server for this run.',
' --allow-http Permit plain http:// URLs with --http-url.',
' --header KEY=value Attach HTTP headers (repeatable).',
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
' --stdio-arg <value> Append args to the stdio command (repeatable).',
' --env KEY=value Inject env vars for stdio servers (repeatable).',

View File

@ -18,6 +18,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
export const CALL_HELP_ADHOC_SERVER_LINES = [
' --http-url <url> Register an HTTP server for this run.',
' --allow-http Permit plain http:// URLs with --http-url.',
' --header KEY=value Attach HTTP headers (repeatable).',
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
' --stdio-arg <value> Append args to the stdio command (repeatable).',
' --env KEY=value Inject env vars for stdio servers (repeatable).',

View File

@ -66,21 +66,24 @@ export function extractEphemeralServerFlags(
if (token === '--env') {
const value = args[index + 1];
if (!value?.includes('=')) {
throw new Error("Flag '--env' requires KEY=value.");
}
const [key, ...rest] = value.split('=');
if (!key) {
throw new Error("Flag '--env' requires KEY=value.");
}
const current = ensureSpec();
const envMap = current.env ? { ...current.env } : {};
envMap[key] = rest.join('=');
parseKeyValue(value, envMap, '--env');
current.env = envMap;
args.splice(index, 2);
continue;
}
if (token === '--header') {
const value = args[index + 1];
const current = ensureSpec();
const headerMap = current.headers ? { ...current.headers } : {};
parseKeyValue(value, headerMap, '--header');
current.headers = headerMap;
args.splice(index, 2);
continue;
}
if (token === '--cwd') {
const value = args[index + 1];
if (!value) {
@ -126,3 +129,14 @@ export function extractEphemeralServerFlags(
return spec;
}
function parseKeyValue(value: string | undefined, target: Record<string, string>, flagName: string): void {
if (!value?.includes('=')) {
throw new Error(`Flag '${flagName}' requires KEY=value.`);
}
const [key, ...rest] = value.split('=');
if (!key) {
throw new Error(`Flag '${flagName}' requires KEY=value.`);
}
target[key] = rest.join('=');
}

View File

@ -362,6 +362,7 @@ export function printListHelp(): void {
'Ad-hoc servers:',
' --http-url <url> Register an HTTP server for this run.',
' --allow-http Permit plain http:// URLs with --http-url.',
' --header KEY=value Attach HTTP headers (repeatable).',
' --stdio <command> Run a stdio MCP server (repeat --stdio-arg for args).',
' --stdio-arg <value> Append args to the stdio command (repeatable).',
' --env KEY=value Inject env vars for stdio servers (repeatable).',

View File

@ -10,6 +10,28 @@ describe('resolveEphemeralServer', () => {
expect(headers?.accept?.toLowerCase()).toContain('text/event-stream');
});
it('preserves ad-hoc HTTP headers in runtime and persisted definitions', () => {
const { definition, persistedEntry } = resolveEphemeralServer({
httpUrl: 'https://example.com/mcp',
headers: {
Authorization: '$env:API_TOKEN',
'X-Tenant': 'biz-unit-01',
},
});
expect(definition.command.kind).toBe('http');
const headers = definition.command.kind === 'http' ? definition.command.headers : undefined;
expect(headers).toMatchObject({
Authorization: '$env:API_TOKEN',
'X-Tenant': 'biz-unit-01',
});
expect(headers?.accept?.toLowerCase()).toContain('application/json');
expect(headers?.accept?.toLowerCase()).toContain('text/event-stream');
expect(persistedEntry.headers).toEqual({
Authorization: '$env:API_TOKEN',
'X-Tenant': 'biz-unit-01',
});
});
it('auto-enables keep-alive for STDIO commands that match known signatures', () => {
const { definition, persistedEntry } = resolveEphemeralServer({
stdioCommand: 'npx -y chrome-devtools-mcp@latest',

View File

@ -3,13 +3,33 @@ import { describe, expect, it } from 'vitest';
import { extractEphemeralServerFlags } from '../src/cli/ephemeral-flags.js';
describe('extractEphemeralServerFlags', () => {
it('parses HTTP URLs and env overrides', () => {
const args = ['--http-url', 'https://mcp.example.com/mcp', '--env', 'TOKEN=abc', 'list'];
it('parses HTTP URLs, headers, and env overrides', () => {
const args = [
'--http-url',
'https://mcp.example.com/mcp',
'--env',
'TOKEN=abc',
'--header',
'Authorization=$env:API_TOKEN',
'--header',
'X-Tenant=biz=unit',
'list',
];
const spec = extractEphemeralServerFlags(args);
expect(spec).toEqual({ httpUrl: 'https://mcp.example.com/mcp', env: { TOKEN: 'abc' } });
expect(spec).toEqual({
httpUrl: 'https://mcp.example.com/mcp',
env: { TOKEN: 'abc' },
headers: { Authorization: '$env:API_TOKEN', 'X-Tenant': 'biz=unit' },
});
expect(args).toEqual(['list']);
});
it('rejects malformed ad-hoc headers', () => {
expect(() =>
extractEphemeralServerFlags(['--http-url', 'https://mcp.example.com/mcp', '--header', 'oops'])
).toThrow("Flag '--header' requires KEY=value.");
});
it('captures stdio commands and additional args', () => {
const args = ['--stdio', 'bun run ./server.ts', '--stdio-arg', '--watch', 'call'];
const spec = extractEphemeralServerFlags(args);