diff --git a/.changeset/openapi-compact-security.md b/.changeset/openapi-compact-security.md new file mode 100644 index 0000000..ca811ec --- /dev/null +++ b/.changeset/openapi-compact-security.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Added `compact` and `security` OpenAPI config options for trimming generated command schemas and skipping credential option injection. diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index ee8240c..bdb8352 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -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> + } + + // 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' }, diff --git a/src/Openapi.ts b/src/Openapi.ts index fba99e1..0f8b912 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -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. */ @@ -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({ @@ -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 @@ -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, + seen = new Map>(), +): Record { + // 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[]) + .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 = {} + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function headerOptions(parameters: Parameter[]): HeaderParameter[] { const seen = new Set() const headers: HeaderParameter[] = []