Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thread-request-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Added `context.request` for HTTP and MCP invocations and `openapiConfig.forwardHeaders` for propagating caller headers to upstream APIs.
1 change: 1 addition & 0 deletions src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Request | undefined>()
return { pong: true }
},
})
Expand Down
81 changes: 80 additions & 1 deletion src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any, any, any>, req: Request) {
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -5600,10 +5638,16 @@ describe('fetch', () => {
return cli
}

async function mcpRequest(cli: Cli.Cli<any, any, any>, body: unknown, sessionId?: string) {
async function mcpRequest(
cli: Cli.Cli<any, any, any>,
body: unknown,
sessionId?: string,
extraHeaders: Record<string, string> = {},
) {
const headers: Record<string, string> = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
...extraHeaders,
}
if (sessionId) headers['mcp-session-id'] = sessionId
return cli.fetch(
Expand Down Expand Up @@ -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'))
Expand Down
10 changes: 8 additions & 2 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1827,12 +1827,14 @@ function createMcpHttpHandler(
},
async (...callArgs: any[]) => {
const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
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,
})
},
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})
Expand All @@ -2043,6 +2045,7 @@ async function executeCommand(
command: CommandDefinition<any, any, any>,
rest: string[],
inputOptions: Record<string, unknown>,
request: Request,
start: number,
options: fetchImpl.Options,
): Promise<Response> {
Expand Down Expand Up @@ -2097,6 +2100,7 @@ async function executeCommand(
name: options.name ?? path,
parseMode: 'split',
path,
request,
vars: options.vars,
version: options.version,
})
Expand Down Expand Up @@ -3684,6 +3688,8 @@ type CommandDefinition<
name: string
/** Return a success result with optional metadata (e.g. CTAs). */
ok: (data: InferReturn<output>, 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<options>
/** Variables set by middleware. */
var: InferVars<vars>
Expand Down
3 changes: 3 additions & 0 deletions src/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
name?: string | undefined
version?: string | undefined
Expand Down Expand Up @@ -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,
})
Expand Down
100 changes: 100 additions & 0 deletions src/Openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'])

Expand Down
10 changes: 10 additions & 0 deletions src/Openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -437,6 +439,7 @@ export async function generateCommands(
run: createHandler({
basePath: options.basePath,
fetch,
forwardHeaders: config?.forwardHeaders,
httpMethod,
path,
headerParams,
Expand Down Expand Up @@ -678,6 +681,7 @@ function createHandler(config: {
basePath?: string | undefined
bodyProps: Record<string, Record<string, unknown>>
fetch: FetchHandler
forwardHeaders?: string[] | undefined
headerParams: HeaderParameter[]
httpMethod: string
path: string
Expand Down Expand Up @@ -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')

Expand Down
5 changes: 5 additions & 0 deletions src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
globals = {},
vars: varsSchema,
middlewares = [],
request,
} = options
const displayName = options.displayName ?? name
const parseMode = options.parseMode ?? 'argv'
Expand Down Expand Up @@ -115,6 +116,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
name,
ok: okFn,
options: parsedOptions,
request,
var: varsMap,
version,
})
Expand Down Expand Up @@ -204,6 +206,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
formatExplicit,
globals,
name,
request,
set(key: string, value: unknown) {
varsMap[key] = value
},
Expand Down Expand Up @@ -304,6 +307,8 @@ export declare namespace execute {
parseMode?: 'argv' | 'split' | 'flat' | undefined
/** The resolved command path. */
path: string
/** The inbound HTTP request when invoked via HTTP or HTTP MCP; undefined for CLI/stdio invocations. */
request?: Request | undefined
/** Vars schema for middleware variables. */
vars?: z.ZodObject<any> | undefined
/** CLI version string. */
Expand Down
Loading
Loading