feat: support ad-hoc HTTP headers
This commit is contained in:
parent
45dcb6561e
commit
3f4f8dc317
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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).',
|
||||
|
||||
@ -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).',
|
||||
|
||||
@ -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('=');
|
||||
}
|
||||
|
||||
@ -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).',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user