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/openapi-compact-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Added `compact` and `security` OpenAPI config options for trimming generated command schemas and skipping credential option injection.
92 changes: 92 additions & 0 deletions src/Openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,98 @@ describe('generateCommands', () => {
expect(limitSchema.description).toBe('Max results')
})

test('compact strips examples and oversized patterns', async () => {
const longPattern = `^${'(?:x|y)'.repeat(60)}$`
const compactSpec = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/items': {
get: {
operationId: 'listItems',
parameters: [
{
name: 'address',
in: 'query',
schema: {
type: 'string',
pattern: '^0x[0-9a-fA-F]{40}$',
examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'],
},
},
{
name: 'timestamp',
in: 'query',
schema: {
type: 'string',
description: 'ISO 8601 timestamp',
allOf: [{ pattern: longPattern }, { pattern: longPattern }],
},
},
{
name: 'created',
in: 'query',
schema: { type: 'string', format: 'date-time', description: 'Creation time' },
},
],
responses: { '200': { description: 'OK' } },
},
},
},
} as const

const commands = await Openapi.generateCommands(compactSpec, app.fetch, {
config: { compact: true },
})
const cmd = commands.get('listItems')!
if ('_group' in cmd) throw new Error('expected listItems command')
const schema = z.toJSONSchema(cmd.options!, { io: 'input' }) as {
properties: Record<string, Record<string, unknown>>
}

// Short patterns survive; examples and oversized patterns are stripped.
expect(schema.properties.address).toMatchObject({ pattern: '^0x[0-9a-fA-F]{40}$' })
expect(schema.properties.address!.examples).toBeUndefined()
expect(schema.properties.timestamp).toMatchObject({ description: 'ISO 8601 timestamp' })
expect(JSON.stringify(schema)).not.toContain('(?:x|y)')
// Date/time formats are dropped so zod cannot re-derive its oversized ISO regex.
expect(schema.properties.created).toEqual({ type: 'string', description: 'Creation time' })
})

test('security: false skips credential options', async () => {
const securedSpec = {
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
components: {
securitySchemes: {
tokenAuth: { type: 'apiKey', in: 'header', name: 'x-api-key' },
bearerAuth: { type: 'http', scheme: 'bearer' },
},
},
security: [{ tokenAuth: [] }, { bearerAuth: [] }],
paths: {
'/secret': {
get: {
operationId: 'getSecret',
responses: { '200': { description: 'OK' } },
},
},
},
} as const

const generated = await Openapi.generateCommands(securedSpec, app.fetch)
const secured = generated.get('getSecret')!
if ('_group' in secured) throw new Error('expected getSecret command')
expect(Object.keys(secured.options!.shape)).toEqual(['x-api-key', 'authorization'])

const skipped = await Openapi.generateCommands(securedSpec, app.fetch, {
config: { security: false },
})
const bare = skipped.get('getSecret')!
if ('_group' in bare) throw new Error('expected getSecret command')
expect(bare.options).toBeUndefined()
})

test('generates namespace command groups from paths', async () => {
const commands = await Openapi.generateCommands(spec, app.fetch, {
config: { mode: 'namespace' },
Expand Down
71 changes: 70 additions & 1 deletion src/Openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ export type Mode = 'namespace' | 'operation'

/** Configuration for generating commands from an OpenAPI document. */
export type Config = {
/** Strips `examples`, oversized `pattern` regexes, and regex-deriving date/time formats from generated schemas, shrinking MCP tool listings. Defaults to `false`. */
compact?: boolean | undefined
/** 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
/** Generates credential options from the document's `security` requirements. Defaults to `true`. */
security?: boolean | undefined
}

/** Options for generating an OpenAPI document from an incur CLI. */
Expand Down Expand Up @@ -371,6 +375,7 @@ export async function generateCommands(
const operations = openapiOperations(paths)
const namespaceInfo = getNamespaceInfo(operations)
const { config } = options
if (config?.compact) compactOperations(operations)

for (const { method, operation: op, path } of operations) {
const segments = commandSegments({
Expand All @@ -386,7 +391,7 @@ export async function generateCommands(
const queryParams = (op.parameters ?? []).filter((p) => p.in === 'query')
const headerParams = headerOptions([
...(op.parameters ?? []).filter((p) => p.in === 'header'),
...securityHeaderParams(resolved, op),
...(config?.security === false ? [] : securityHeaderParams(resolved, op)),
])

const bodySchema = op.requestBody?.content?.['application/json']?.schema
Expand Down Expand Up @@ -533,6 +538,70 @@ function securityHeaderParam(

const authorizationSchemes = new Set(['basic', 'bearer'])

/** Patterns longer than this are dropped by `compact`; oversized regexes add schema bytes without helping callers. */
const compactPatternLimit = 100

/** String formats zod serializes back into oversized regex patterns. */
const dateTimeFormats = new Set(['date', 'date-time', 'duration', 'time'])

/** Rewrites operation parameter and request-body schemas with `compactSchema` in place. */
function compactOperations(operations: OperationEntry[]) {
for (const { operation } of operations) {
for (const parameter of operation.parameters ?? [])
if (parameter.schema) parameter.schema = compactSchema(parameter.schema)
const body = operation.requestBody?.content?.['application/json']
if (body?.schema) body.schema = compactSchema(body.schema)
}
}

/** Returns a copy stripped of `example(s)` and oversized `pattern` regexes; drops subschemas emptied by stripping. */
function compactSchema(
schema: Record<string, unknown>,
seen = new Map<object, Record<string, unknown>>(),
): Record<string, unknown> {
// Dereferenced documents may share or cycle subschemas; reuse the copy.
const cached = seen.get(schema)
if (cached) return cached
const out = { ...schema }
seen.set(schema, out)

delete out.example
delete out.examples
if (typeof out.pattern === 'string' && out.pattern.length > compactPatternLimit)
delete out.pattern
// Zod re-derives oversized ISO regexes from date/time formats when tools serialize; the description carries the shape.
if (typeof out.format === 'string' && dateTimeFormats.has(out.format)) delete out.format

for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
if (!Array.isArray(out[key])) continue
const entries = (out[key] as Record<string, unknown>[])
.map((entry) => compactSchema(entry, seen))
.filter((entry) => Object.keys(entry).length > 0)
if (entries.length > 0) out[key] = entries
else delete out[key]
}

for (const key of ['additionalProperties', 'contains', 'items', 'not', 'propertyNames']) {
const value = out[key]
if (isSchemaObject(value)) out[key] = compactSchema(value, seen)
}

for (const key of ['$defs', 'patternProperties', 'properties']) {
const value = out[key]
if (!isSchemaObject(value)) continue
const map: Record<string, unknown> = {}
for (const [name, entry] of Object.entries(value))
map[name] = isSchemaObject(entry) ? compactSchema(entry, seen) : entry
out[key] = map
}

return out
}

function isSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function headerOptions(parameters: Parameter[]): HeaderParameter[] {
const seen = new Set<string>()
const headers: HeaderParameter[] = []
Expand Down
Loading