From 3f4f8dc317e4453ce37d2ae439c5c4f4846b9b85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 05:49:25 +0100 Subject: [PATCH] feat: support ad-hoc HTTP headers --- CHANGELOG.md | 1 + docs/adhoc.md | 4 ++-- docs/config.md | 3 ++- src/cli/adhoc-server.ts | 4 +++- src/cli/auth-command.ts | 1 + src/cli/call-help.ts | 1 + src/cli/ephemeral-flags.ts | 30 ++++++++++++++++++++++-------- src/cli/list-command.ts | 1 + tests/adhoc-server.test.ts | 22 ++++++++++++++++++++++ tests/cli-ephemeral-flags.test.ts | 26 +++++++++++++++++++++++--- 10 files changed, 78 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b22ab..1137e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/adhoc.md b/docs/adhoc.md index e1050f0..9811f1c 100644 --- a/docs/adhoc.md +++ b/docs/adhoc.md @@ -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 don’t 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 diff --git a/docs/config.md b/docs/config.md index 682e272..c9836d9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 ` (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 diff --git a/src/cli/adhoc-server.ts b/src/cli/adhoc-server.ts index 7d773e0..bf33b02 100644 --- a/src/cli/adhoc-server.ts +++ b/src/cli/adhoc-server.ts @@ -13,6 +13,7 @@ export interface EphemeralServerSpec { stdioArgs?: string[]; cwd?: string; env?: Record; + headers?: Record; 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 }; diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index 5f0a9fa..7fb2a68 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -125,6 +125,7 @@ export function printAuthHelp(): void { 'Ad-hoc targets:', ' --http-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 Run a stdio MCP server (repeat --stdio-arg for args).', ' --stdio-arg Append args to the stdio command (repeatable).', ' --env KEY=value Inject env vars for stdio servers (repeatable).', diff --git a/src/cli/call-help.ts b/src/cli/call-help.ts index 451ae17..573a2c4 100644 --- a/src/cli/call-help.ts +++ b/src/cli/call-help.ts @@ -18,6 +18,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [ export const CALL_HELP_ADHOC_SERVER_LINES = [ ' --http-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 Run a stdio MCP server (repeat --stdio-arg for args).', ' --stdio-arg Append args to the stdio command (repeatable).', ' --env KEY=value Inject env vars for stdio servers (repeatable).', diff --git a/src/cli/ephemeral-flags.ts b/src/cli/ephemeral-flags.ts index e32e131..8c6b3a2 100644 --- a/src/cli/ephemeral-flags.ts +++ b/src/cli/ephemeral-flags.ts @@ -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, 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('='); +} diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts index 3cf3831..7d4a34b 100644 --- a/src/cli/list-command.ts +++ b/src/cli/list-command.ts @@ -362,6 +362,7 @@ export function printListHelp(): void { 'Ad-hoc servers:', ' --http-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 Run a stdio MCP server (repeat --stdio-arg for args).', ' --stdio-arg Append args to the stdio command (repeatable).', ' --env KEY=value Inject env vars for stdio servers (repeatable).', diff --git a/tests/adhoc-server.test.ts b/tests/adhoc-server.test.ts index 67c513e..fb4e6c0 100644 --- a/tests/adhoc-server.test.ts +++ b/tests/adhoc-server.test.ts @@ -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', diff --git a/tests/cli-ephemeral-flags.test.ts b/tests/cli-ephemeral-flags.test.ts index fca29cc..6d9824d 100644 --- a/tests/cli-ephemeral-flags.test.ts +++ b/tests/cli-ephemeral-flags.test.ts @@ -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);