diff --git a/.changeset/thread-request-context.md b/.changeset/thread-request-context.md new file mode 100644 index 00000000..49770c85 --- /dev/null +++ b/.changeset/thread-request-context.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Added `context.request` for HTTP and MCP invocations and `openapiConfig.forwardHeaders` for propagating caller headers to upstream APIs. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 88000402..14bf7c1a 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -33,6 +33,7 @@ test('without schemas, run receives empty objects', () => { run(c) { expectTypeOf(c.args).toEqualTypeOf<{}>() expectTypeOf(c.options).toEqualTypeOf<{}>() + expectTypeOf(c.request).toEqualTypeOf() return { pong: true } }, }) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 9a1fb0d6..2c9bb37e 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4761,6 +4761,28 @@ describe('Command.execute', () => { expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } }) expect(result).not.toHaveProperty('error.fieldErrors') }) + + test('CLI invocation leaves request undefined', async () => { + const result = await Command.execute( + { + run(c: any) { + return { hasRequest: c.request !== undefined } + }, + }, + { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: {}, + name: 'test', + path: 'users', + version: undefined, + }, + ) + + expect(result).toMatchObject({ ok: true, data: { hasRequest: false } }) + }) }) async function fetchJson(cli: Cli.Cli, req: Request) { @@ -4898,6 +4920,22 @@ describe('fetch', () => { `) }) + test('HTTP route exposes inbound request to command context', async () => { + const cli = Cli.create('test') + cli.command('auth', { + run: (c) => ({ authorization: c.request?.headers.get('authorization') }), + }) + const req = new Request('http://localhost/auth', { + headers: { authorization: 'Bearer t' }, + }) + const { body } = await fetchJson(cli, req) + expect(body.data).toMatchInlineSnapshot(` + { + "authorization": "Bearer t", + } + `) + }) + test('POST body → options', async () => { const cli = Cli.create('test') cli.command('users', { @@ -5600,10 +5638,16 @@ describe('fetch', () => { return cli } - async function mcpRequest(cli: Cli.Cli, body: unknown, sessionId?: string) { + async function mcpRequest( + cli: Cli.Cli, + body: unknown, + sessionId?: string, + extraHeaders: Record = {}, + ) { const headers: Record = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', + ...extraHeaders, } if (sessionId) headers['mcp-session-id'] = sessionId return cli.fetch( @@ -5784,6 +5828,41 @@ describe('fetch', () => { `) }) + test('POST /mcp exposes per-request headers to command context', async () => { + const cli = Cli.create('test', { version: '1.0.0' }) + cli.command('auth', { + description: 'Auth', + run: (c) => ({ authorization: c.request?.headers.get('authorization') }), + }) + + const call = async (authorization: string) => { + const res = await mcpRequest( + cli, + { + jsonrpc: '2.0', + id: authorization, + method: 'tools/call', + params: { name: 'auth', arguments: {} }, + }, + undefined, + { authorization }, + ) + const body = await res.json() + return JSON.parse(body.result.content[0].text) + } + + expect(await call('Bearer one')).toMatchInlineSnapshot(` + { + "authorization": "Bearer one", + } + `) + expect(await call('Bearer two')).toMatchInlineSnapshot(` + { + "authorization": "Bearer two", + } + `) + }) + test('non-/mcp paths still route to command API', async () => { const cli = mcpCli() const { body } = await fetchJson(cli, new Request('http://localhost/ping')) diff --git a/src/Cli.ts b/src/Cli.ts index 5f392ce9..13bb1acd 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1827,12 +1827,14 @@ function createMcpHttpHandler( }, async (...callArgs: any[]) => { const params = hasInput ? (callArgs[0] as Record) : {} + const extra = hasInput ? callArgs[1] : callArgs[0] return Mcp.callTool(tool, params, { name, version, middlewares: mcpOptions?.middlewares, env: mcpOptions?.env, vars: mcpOptions?.vars, + request: extra?.http?.req, }) }, ) @@ -1984,7 +1986,7 @@ async function fetchImpl( if (segments.length === 0) { // Root path if (options.rootCommand) - return executeCommand(name, options.rootCommand, [], inputOptions, start, options) + return executeCommand(name, options.rootCommand, [], inputOptions, req, start, options) return jsonResponse( { ok: false, @@ -2031,7 +2033,7 @@ async function fetchImpl( const { command, path, rest } = resolved const groupMiddlewares = 'middlewares' in resolved ? resolved.middlewares : [] - return executeCommand(path, command, rest, inputOptions, start, { + return executeCommand(path, command, rest, inputOptions, req, start, { ...options, groupMiddlewares, }) @@ -2043,6 +2045,7 @@ async function executeCommand( command: CommandDefinition, rest: string[], inputOptions: Record, + request: Request, start: number, options: fetchImpl.Options, ): Promise { @@ -2097,6 +2100,7 @@ async function executeCommand( name: options.name ?? path, parseMode: 'split', path, + request, vars: options.vars, version: options.version, }) @@ -3684,6 +3688,8 @@ type CommandDefinition< name: string /** Return a success result with optional metadata (e.g. CTAs). */ ok: (data: InferReturn, meta?: { cta?: CtaBlock | undefined }) => never + /** The inbound HTTP request when invoked via HTTP or HTTP MCP; undefined for CLI/stdio invocations. */ + request?: Request | undefined options: InferOutput /** Variables set by middleware. */ var: InferVars diff --git a/src/Mcp.ts b/src/Mcp.ts index 74781628..631c3fa3 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -127,6 +127,8 @@ export async function callTool( extra?: { mcpReq?: { _meta?: { progressToken?: string | number } } } + /** The inbound HTTP request when invoked via HTTP MCP. */ + request?: Request | undefined sendNotification?: (n: ProgressNotification) => Promise name?: string | undefined version?: string | undefined @@ -157,6 +159,7 @@ export async function callTool( name: options.name ?? tool.name, parseMode: 'flat', path: tool.name, + request: options.request, vars: options.vars, version: options.version, }) diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 50ad108e..14772958 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -266,6 +266,35 @@ describe('cli integration', () => { }) } + function createForwardHeadersCli(forwardHeaders?: string[] | undefined) { + return Cli.create('test', { description: 'test' }).command('api', { + fetch(request) { + return Response.json({ authorization: request.headers.get('authorization') }) + }, + openapi: { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/secret': { + get: { + operationId: 'getSecret', + parameters: [ + { + name: 'authorization', + in: 'header', + required: false, + schema: { type: 'string' }, + }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + ...(forwardHeaders ? { openapiConfig: { forwardHeaders } } : undefined), + }) + } + test('GET /users via operationId', async () => { const { output } = await serve(createCli(), ['api', 'listUsers']) expect(output).toContain('Alice') @@ -345,6 +374,77 @@ describe('cli integration', () => { expect(json(output).authorization).toMatchInlineSnapshot(`"Bearer secret"`) }) + test('forwardHeaders copies caller headers for HTTP routes', async () => { + const res = await createForwardHeadersCli(['authorization']).fetch( + new Request('http://localhost/api/getSecret', { + headers: { authorization: 'Bearer caller' }, + }), + ) + const body = await res.json() + + expect(body.data).toMatchInlineSnapshot(` + { + "authorization": "Bearer caller", + } + `) + }) + + test('forwardHeaders defaults to no caller header forwarding', async () => { + const res = await createForwardHeadersCli().fetch( + new Request('http://localhost/api/getSecret', { + headers: { authorization: 'Bearer caller' }, + }), + ) + const body = await res.json() + + expect(body.data).toMatchInlineSnapshot(` + { + "authorization": null, + } + `) + }) + + test('explicit header options beat forwarded caller headers', async () => { + const res = await createForwardHeadersCli(['authorization']).fetch( + new Request('http://localhost/api/getSecret?authorization=Bearer%20explicit', { + headers: { authorization: 'Bearer caller' }, + }), + ) + const body = await res.json() + + expect(body.data).toMatchInlineSnapshot(` + { + "authorization": "Bearer explicit", + } + `) + }) + + test('forwardHeaders copies caller headers for HTTP MCP calls', async () => { + const res = await createForwardHeadersCli(['authorization']).fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + authorization: 'Bearer caller', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'api_getSecret', arguments: {} }, + }), + }), + ) + const body = await res.json() + + expect(JSON.parse(body.result.content[0].text)).toMatchInlineSnapshot(` + { + "authorization": "Bearer caller", + } + `) + }) + test('bearer auth option appears in generated command help', async () => { const { output } = await serve(createBearerAuthCli(), ['api', 'getSecret', '--help']) diff --git a/src/Openapi.ts b/src/Openapi.ts index a2d73d64..589d78a7 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -32,6 +32,8 @@ export type Mode = 'namespace' | 'operation' /** Configuration for generating commands from an OpenAPI document. */ export type Config = { + /** Header names copied from the inbound request onto upstream requests when not explicitly set. */ + forwardHeaders?: string[] | undefined /** Command naming strategy. Defaults to `'operation'`. */ mode?: Mode | undefined } @@ -437,6 +439,7 @@ export async function generateCommands( run: createHandler({ basePath: options.basePath, fetch, + forwardHeaders: config?.forwardHeaders, httpMethod, path, headerParams, @@ -678,6 +681,7 @@ function createHandler(config: { basePath?: string | undefined bodyProps: Record> fetch: FetchHandler + forwardHeaders?: string[] | undefined headerParams: HeaderParameter[] httpMethod: string path: string @@ -723,6 +727,12 @@ function createHandler(config: { if (value !== undefined) input.headers.set(p.name, String(value)) } + for (const name of config.forwardHeaders ?? []) { + const value = context.request?.headers.get(name) + if (!input.headers.has(name) && value !== null && value !== undefined) + input.headers.set(name, value) + } + if (body && !input.headers.has('content-type')) input.headers.set('content-type', 'application/json') diff --git a/src/internal/command.ts b/src/internal/command.ts index 83a17069..7b298302 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -47,6 +47,7 @@ export async function execute(command: any, options: execute.Options): Promise | undefined /** CLI version string. */ diff --git a/src/middleware.test.ts b/src/middleware.test.ts index bc3564a7..c740ff65 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,6 +1,32 @@ +import { z } from 'zod' + +import * as Cli from './Cli.js' import middleware from './middleware.js' test('returns the handler unchanged', () => { const handler = vi.fn() expect(middleware(handler)).toBe(handler) }) + +test('HTTP route exposes inbound request to middleware context', async () => { + const vars = z.object({ authorization: z.string().nullable().default(null) }) + const cli = Cli.create('test', { vars }) + .use( + middleware((c, next) => { + c.set('authorization', c.request?.headers.get('authorization') ?? null) + return next() + }), + ) + .command('auth', { + run: (c) => ({ authorization: c.var.authorization }), + }) + + const res = await cli.fetch( + new Request('http://localhost/auth', { headers: { authorization: 'Bearer t' } }), + ) + + expect(await res.json()).toMatchObject({ + ok: true, + data: { authorization: 'Bearer t' }, + }) +}) diff --git a/src/middleware.ts b/src/middleware.ts index 11220471..5b061f0d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -59,6 +59,8 @@ export type Context< globals: InferGlobals /** The CLI name. */ name: string + /** The inbound HTTP request when invoked via HTTP or HTTP MCP; undefined for CLI/stdio invocations. */ + request?: Request | undefined /** Set a typed variable for downstream middleware and handlers. */ set>(key: key, value: InferVars[key]): void /** Variables set by upstream middleware. */