create-solvapay 0.8.0 → 0.8.1-preview-b07b713629dff72ea6d257f1f5145cb01ac62f2c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-solvapay",
3
- "version": "0.8.0",
3
+ "version": "0.8.1-preview-b07b713629dff72ea6d257f1f5145cb01ac62f2c",
4
4
  "description": "Scaffold SolvaPay-monetized projects — MCP servers on Cloudflare Workers and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -198,7 +198,14 @@ export function listOperations(spec) {
198
198
  const op = pathItem[method]
199
199
  if (!op || typeof op !== 'object') continue
200
200
  const operationId = op.operationId ?? defaultOperationId(method, path)
201
- const parameters = mergeParameters(pathLevelParams, op.parameters ?? [])
201
+ const merged = mergeParameters(pathLevelParams, op.parameters ?? [])
202
+ // Swagger 2.0 `type: file` form fields carry raw bytes the LLM can't
203
+ // supply, so they never become tool inputs; the operation is flagged
204
+ // instead (suggestTier skips it).
205
+ const hasFileUpload = merged.some(p => p.in === 'formData' && p.type === 'file')
206
+ const parameters = merged.filter(
207
+ p => p.in !== 'body' && !(p.in === 'formData' && p.type === 'file'),
208
+ )
202
209
  operations.push({
203
210
  operationId,
204
211
  method: method.toUpperCase(),
@@ -208,7 +215,11 @@ export function listOperations(spec) {
208
215
  deprecated: op.deprecated === true,
209
216
  tags: Array.isArray(op.tags) ? op.tags : [],
210
217
  parameters,
211
- requestBody: extractRequestBody(op.requestBody),
218
+ requestBody:
219
+ extractRequestBody(op.requestBody) ??
220
+ extractSwagger2Body(spec, op, pathLevelParams),
221
+ formEncoding: formEncodingFor(spec, op, parameters, hasFileUpload),
222
+ hasFileUpload,
212
223
  security: op.security ?? spec.security ?? null,
213
224
  raw: op,
214
225
  })
@@ -313,6 +324,7 @@ function resolveOauth2ClientCredentialsFlow(scheme) {
313
324
  export function suggestTier(operation) {
314
325
  if (operation.deprecated) return 'skip'
315
326
  if (operation.raw?.['x-internal'] === true) return 'skip'
327
+ if (operation.hasFileUpload) return 'skip'
316
328
  if (operation.method === 'GET' || operation.method === 'HEAD') return 'free'
317
329
  if (MUTATING_METHODS.has(operation.method)) return 'paid'
318
330
  return 'free'
@@ -342,10 +354,10 @@ export function synthesizeExamples(operation) {
342
354
  if (operation.requestBody.example !== undefined) {
343
355
  // Media-type-level example takes precedence over schema synthesis.
344
356
  const value = operation.requestBody.example
345
- Object.assign(inputs, isPlainObject(value) ? value : { body: value })
357
+ inputs.body = value
346
358
  } else if (operation.requestBody.schema) {
347
359
  const { value, source } = pickSchemaValue(operation.requestBody.schema)
348
- Object.assign(inputs, isPlainObject(value) ? value : { body: value })
360
+ inputs.body = value
349
361
  if (source === 'placeholder') quality = 'placeholder'
350
362
  }
351
363
  }
@@ -562,7 +574,9 @@ function mergeParameters(pathLevel, opLevel) {
562
574
  }
563
575
 
564
576
  function normaliseParameter(param) {
565
- const schema = param.schema ?? {}
577
+ // Swagger 2.0 non-body parameters declare type / format / enum /
578
+ // default on the parameter itself rather than under `schema`.
579
+ const schema = param.schema ?? param
566
580
  // Parameter-level `description` takes precedence over the inner
567
581
  // `schema.description` (OpenAPI 3 allows both; the parameter one is
568
582
  // the author's primary intent — schema-level usually describes the
@@ -589,6 +603,31 @@ function normaliseParameter(param) {
589
603
  }
590
604
  }
591
605
 
606
+ // Swagger 2.0 carries the JSON body as an `in: body` parameter instead
607
+ // of `requestBody`. Lift it into the same shape so codegen sends it.
608
+ // Skipped when the operation only consumes non-JSON media types.
609
+ function extractSwagger2Body(spec, op, pathLevelParams) {
610
+ const params = [...(op.parameters ?? []), ...pathLevelParams]
611
+ const body = params.find(p => p && typeof p === 'object' && p.in === 'body')
612
+ if (!body) return null
613
+ const consumes = op.consumes ?? spec.consumes
614
+ if (Array.isArray(consumes) && consumes.length > 0 && !consumes.some(c => /json/i.test(c))) {
615
+ return null
616
+ }
617
+ return { required: body.required === true, schema: body.schema ?? null, example: undefined }
618
+ }
619
+
620
+ // How Swagger 2.0 `in: formData` fields are sent: `multipart` when the
621
+ // operation only accepts multipart (or uploads a file), otherwise
622
+ // `urlencoded`. Null when there are no form fields.
623
+ function formEncodingFor(spec, op, parameters, hasFileUpload) {
624
+ if (!parameters.some(p => p.in === 'formData')) return null
625
+ const consumes = op.consumes ?? spec.consumes ?? []
626
+ const urlencoded = consumes.some(c => /x-www-form-urlencoded/i.test(c))
627
+ const multipart = consumes.some(c => /multipart\/form-data/i.test(c))
628
+ return multipart && (hasFileUpload || !urlencoded) ? 'multipart' : 'urlencoded'
629
+ }
630
+
592
631
  function extractRequestBody(requestBody) {
593
632
  if (!requestBody || typeof requestBody !== 'object') return null
594
633
  const json = requestBody.content?.['application/json']
@@ -629,7 +629,8 @@ function renderToolFile({ operation, schemes, tier, auth, serverBaseUrl, freeLim
629
629
  const fnName = `register${toPascalIdentifier(operation.operationId)}`
630
630
  const urlTemplate = renderUrlTemplate(serverBaseUrl, operation)
631
631
  const headerLines = renderHeaderLines(auth, schemes, operation)
632
- const includesEnv = headerLines.length > 0
632
+ // Matches the aggregator, which passes `env` whenever auth isn't `none`.
633
+ const includesEnv = auth.kind !== 'none'
633
634
  const needsAccessToken = auth.kind === 'oauth2-client-credentials'
634
635
  const signature = includesEnv
635
636
  ? `${fnName}(ctx: AdditionalToolsContext, env: Env)`
@@ -654,11 +655,15 @@ function renderToolFile({ operation, schemes, tier, auth, serverBaseUrl, freeLim
654
655
  // carries `{ status, contentType, bodySnippet }` on the thrown
655
656
  // error so the MCP `isError` envelope tells the LLM (and the
656
657
  // human) exactly why upstream rejected the call.
658
+ const fetchImports = fetchInit.usesStringifyLossless
659
+ ? 'upstreamFetchJson, stringifyLossless'
660
+ : 'upstreamFetchJson'
657
661
  const importLines = [
658
662
  "import { z } from 'zod'",
659
663
  "import type { AdditionalToolsContext } from '@solvapay/mcp'",
660
- "import { upstreamFetchJson } from '../lib/upstreamFetch'",
664
+ `import { ${fetchImports} } from '../lib/upstreamFetch'`,
661
665
  ]
666
+ if (operation.parameters.some(isInt64Param)) importLines.push("import { int64 } from '../lib/int64'")
662
667
  if (needsAccessToken) importLines.push("import { getAccessToken } from '../lib/upstreamOAuth'")
663
668
  if (includesEnv) importLines.push("import type { Env } from '../worker'")
664
669
 
@@ -672,7 +677,10 @@ function renderSchemaFields(operation, indent) {
672
677
  lines.push(`${pad}${jsKey(param.name)}: ${zodForParam(param)},`)
673
678
  }
674
679
  if (operation.requestBody?.schema) {
675
- lines.push(`${pad}body: z.record(z.string(), z.unknown()).optional(),`)
680
+ const bodySchema = isArrayBody(operation)
681
+ ? 'z.array(z.unknown())'
682
+ : 'z.record(z.string(), z.unknown())'
683
+ lines.push(`${pad}body: ${bodySchema}.optional(),`)
676
684
  }
677
685
  if (lines.length === 0) return ''
678
686
  return lines.join('\n')
@@ -694,7 +702,8 @@ function renderSchemaFields(operation, indent) {
694
702
  function zodForParam(param) {
695
703
  const { type, format, required, enum: enumValues, description } = param
696
704
  let z
697
- if (type === 'integer') z = 'z.number().int()'
705
+ if (isInt64Param(param)) z = 'int64'
706
+ else if (type === 'integer') z = 'z.number().int()'
698
707
  else if (type === 'number') z = 'z.number()'
699
708
  else if (type === 'boolean') z = 'z.boolean()'
700
709
  else if (type === 'array') z = 'z.array(z.unknown())'
@@ -713,6 +722,35 @@ function zodForParam(param) {
713
722
  return required ? z : `${z}.optional()`
714
723
  }
715
724
 
725
+ // 64-bit integers exceed JavaScript's exact range (2^53 - 1), so they use
726
+ // the template's `int64` schema (safe number or digit string) instead of
727
+ // `z.number().int()`.
728
+ function isInt64Param(param) {
729
+ return param.type === 'integer' && (param.format === 'int64' || param.format === 'uint64')
730
+ }
731
+
732
+ // Names of every integer-typed property in a (dereferenced) request body
733
+ // schema, walking nested objects, array items and composition keywords.
734
+ // The generated tool passes them to `stringifyLossless` so int64 values
735
+ // the LLM sends as digit strings go upstream as exact JSON numbers.
736
+ // Collecting all integers (not only `int64`) is safe: the serializer only
737
+ // rewrites values that are already digit strings.
738
+ function collectIntegerPropertyNames(schema, names = new Set(), seen = new Set()) {
739
+ if (!schema || typeof schema !== 'object' || seen.has(schema)) return names
740
+ seen.add(schema)
741
+ for (const [name, prop] of Object.entries(schema.properties ?? {})) {
742
+ if (prop && typeof prop === 'object' && prop.type === 'integer') names.add(name)
743
+ collectIntegerPropertyNames(prop, names, seen)
744
+ }
745
+ collectIntegerPropertyNames(schema.items, names, seen)
746
+ for (const key of ['allOf', 'oneOf', 'anyOf']) {
747
+ for (const sub of Array.isArray(schema[key]) ? schema[key] : []) {
748
+ collectIntegerPropertyNames(sub, names, seen)
749
+ }
750
+ }
751
+ return names
752
+ }
753
+
716
754
  function isScalarStringEnum(enumValues, type) {
717
755
  if (!Array.isArray(enumValues) || enumValues.length === 0) return false
718
756
  if (type !== undefined && type !== 'string') return false
@@ -732,6 +770,25 @@ function renderUrlTemplate(baseUrl, operation) {
732
770
  return `\`${joinUrl(baseUrl, path)}\``
733
771
  }
734
772
 
773
+ function isArrayBody(operation) {
774
+ return operation.requestBody?.schema?.type === 'array'
775
+ }
776
+
777
+ // Swagger 2.0 `in: formData` fields, sent as URLSearchParams or FormData
778
+ // per `operation.formEncoding`. fetch sets the matching content-type
779
+ // (including the multipart boundary) from the body type.
780
+ function renderFormAssign(operation) {
781
+ const fields = operation.parameters.filter(p => p.in === 'formData')
782
+ if (fields.length === 0 || !operation.formEncoding) return ''
783
+ const ctor = operation.formEncoding === 'multipart' ? 'FormData' : 'URLSearchParams'
784
+ const lines = [` const form = new ${ctor}()`]
785
+ for (const field of fields) {
786
+ const access = `input.${jsAccessor(field.name)}`
787
+ lines.push(` if (${access} !== undefined) form.set('${field.name}', String(${access}))`)
788
+ }
789
+ return lines.join('\n')
790
+ }
791
+
735
792
  function renderFetchInit(operation, headerLines) {
736
793
  const method = operation.method
737
794
  const hasBody = !!operation.requestBody?.schema && method !== 'GET' && method !== 'HEAD'
@@ -741,19 +798,30 @@ function renderFetchInit(operation, headerLines) {
741
798
  const access = `input.${jsAccessor(p.name)}`
742
799
  return ` if (${access} !== undefined) url.searchParams.set('${p.name}', String(${access}))`
743
800
  })
801
+ .concat(renderFormAssign(operation) || [])
744
802
  .join('\n')
745
803
 
746
804
  const headersBlock = headerLines.length > 0
747
805
  ? ` headers: { ${headerLines.join(', ')} },\n`
748
806
  : ''
749
- const bodyBlock = hasBody ? ' body: JSON.stringify(input.body ?? {}),\n' : ''
807
+ const integerKeys = hasBody
808
+ ? [...collectIntegerPropertyNames(operation.requestBody.schema)].sort()
809
+ : []
810
+ const usesStringifyLossless = integerKeys.length > 0
811
+ const emptyBody = isArrayBody(operation) ? '[]' : '{}'
812
+ const bodyBlock = !hasBody
813
+ ? operation.formEncoding
814
+ ? ' body: form,\n'
815
+ : ''
816
+ : usesStringifyLossless
817
+ ? ` body: stringifyLossless(input.body ?? ${emptyBody}, new Set(${JSON.stringify(integerKeys)})),\n`
818
+ : ` body: JSON.stringify(input.body ?? ${emptyBody}),\n`
750
819
  const methodBlock = ` method: '${method}',\n`
751
820
 
752
- return { method, queryAssign, headersBlock, bodyBlock, methodBlock }
821
+ return { method, queryAssign, headersBlock, bodyBlock, methodBlock, usesStringifyLossless }
753
822
  }
754
823
 
755
824
  function renderHeaderLines(auth, schemes, operation) {
756
- if (auth.kind === 'none') return []
757
825
  // Pick the first supported scheme this operation requires; fall back
758
826
  // to the top-level auth kind when the spec has no operation-level
759
827
  // security override.
@@ -93,7 +93,14 @@ export function listOperations(spec) {
93
93
  const op = pathItem[method]
94
94
  if (!op || typeof op !== 'object') continue
95
95
  const operationId = op.operationId ?? defaultOperationId(method, path)
96
- const parameters = mergeParameters(pathLevelParams, op.parameters ?? [])
96
+ const merged = mergeParameters(pathLevelParams, op.parameters ?? [])
97
+ // Swagger 2.0 `type: file` form fields carry raw bytes the LLM can't
98
+ // supply, so they never become tool inputs; the operation is flagged
99
+ // instead (suggestTier skips it).
100
+ const hasFileUpload = merged.some(p => p.in === 'formData' && p.type === 'file')
101
+ const parameters = merged.filter(
102
+ p => p.in !== 'body' && !(p.in === 'formData' && p.type === 'file'),
103
+ )
97
104
  operations.push({
98
105
  operationId,
99
106
  method: method.toUpperCase(),
@@ -103,7 +110,11 @@ export function listOperations(spec) {
103
110
  deprecated: op.deprecated === true,
104
111
  tags: Array.isArray(op.tags) ? op.tags : [],
105
112
  parameters,
106
- requestBody: extractRequestBody(op.requestBody),
113
+ requestBody:
114
+ extractRequestBody(op.requestBody) ??
115
+ extractSwagger2Body(spec, op, pathLevelParams),
116
+ formEncoding: formEncodingFor(spec, op, parameters, hasFileUpload),
117
+ hasFileUpload,
107
118
  security: op.security ?? spec.security ?? null,
108
119
  raw: op,
109
120
  })
@@ -159,6 +170,7 @@ export function resolveSecuritySchemes(spec) {
159
170
  export function suggestTier(operation) {
160
171
  if (operation.deprecated) return 'skip'
161
172
  if (operation.raw?.['x-internal'] === true) return 'skip'
173
+ if (operation.hasFileUpload) return 'skip'
162
174
  if (operation.method === 'GET' || operation.method === 'HEAD') return 'free'
163
175
  if (MUTATING_METHODS.has(operation.method)) return 'paid'
164
176
  return 'free'
@@ -188,10 +200,10 @@ export function synthesizeExamples(operation) {
188
200
  if (operation.requestBody.example !== undefined) {
189
201
  // Media-type-level example takes precedence over schema synthesis.
190
202
  const value = operation.requestBody.example
191
- Object.assign(inputs, isPlainObject(value) ? value : { body: value })
203
+ inputs.body = value
192
204
  } else if (operation.requestBody.schema) {
193
205
  const { value, source } = pickSchemaValue(operation.requestBody.schema)
194
- Object.assign(inputs, isPlainObject(value) ? value : { body: value })
206
+ inputs.body = value
195
207
  if (source === 'placeholder') quality = 'placeholder'
196
208
  }
197
209
  }
@@ -252,7 +264,9 @@ function mergeParameters(pathLevel, opLevel) {
252
264
  }
253
265
 
254
266
  function normaliseParameter(param) {
255
- const schema = param.schema ?? {}
267
+ // Swagger 2.0 non-body parameters declare type / format / enum /
268
+ // default on the parameter itself rather than under `schema`.
269
+ const schema = param.schema ?? param
256
270
  return {
257
271
  name: param.name,
258
272
  in: param.in,
@@ -267,6 +281,31 @@ function normaliseParameter(param) {
267
281
  }
268
282
  }
269
283
 
284
+ // Swagger 2.0 carries the JSON body as an `in: body` parameter instead
285
+ // of `requestBody`. Lift it into the same shape so codegen sends it.
286
+ // Skipped when the operation only consumes non-JSON media types.
287
+ function extractSwagger2Body(spec, op, pathLevelParams) {
288
+ const params = [...(op.parameters ?? []), ...pathLevelParams]
289
+ const body = params.find(p => p && typeof p === 'object' && p.in === 'body')
290
+ if (!body) return null
291
+ const consumes = op.consumes ?? spec.consumes
292
+ if (Array.isArray(consumes) && consumes.length > 0 && !consumes.some(c => /json/i.test(c))) {
293
+ return null
294
+ }
295
+ return { required: body.required === true, schema: body.schema ?? null, example: undefined }
296
+ }
297
+
298
+ // How Swagger 2.0 `in: formData` fields are sent: `multipart` when the
299
+ // operation only accepts multipart (or uploads a file), otherwise
300
+ // `urlencoded`. Null when there are no form fields.
301
+ function formEncodingFor(spec, op, parameters, hasFileUpload) {
302
+ if (!parameters.some(p => p.in === 'formData')) return null
303
+ const consumes = op.consumes ?? spec.consumes ?? []
304
+ const urlencoded = consumes.some(c => /x-www-form-urlencoded/i.test(c))
305
+ const multipart = consumes.some(c => /multipart\/form-data/i.test(c))
306
+ return multipart && (hasFileUpload || !urlencoded) ? 'multipart' : 'urlencoded'
307
+ }
308
+
270
309
  function extractRequestBody(requestBody) {
271
310
  if (!requestBody || typeof requestBody !== 'object') return null
272
311
  const json = requestBody.content?.['application/json']
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Tool input schema for 64-bit integers (`format: int64`). JavaScript
5
+ * numbers are only exact up to 2^53 - 1, so larger values travel as a
6
+ * string of digits — the same form `upstreamFetchJson` returns them in.
7
+ * Unsafe numbers are rejected instead of being silently rounded.
8
+ */
9
+ export const int64 = z.union([
10
+ z
11
+ .number()
12
+ .int()
13
+ .refine(Number.isSafeInteger, 'Integers this large must be passed as a string of digits'),
14
+ z.string().regex(/^-?\d+$/, 'Expected an integer as a string of digits'),
15
+ ])
@@ -12,6 +12,9 @@
12
12
  * - Throws `UpstreamError` on non-2xx OR JSON parse failure. The
13
13
  * error message names the method, URL, status, content-type, and
14
14
  * a body snippet so the LLM has enough context to recover or stop.
15
+ * - Keeps integers beyond `Number.MAX_SAFE_INTEGER` (int64 IDs,
16
+ * snowflakes) as their exact decimal string instead of silently
17
+ * rounding them. Send them back with `stringifyLossless`.
15
18
  *
16
19
  * The free-tool path (`ctx.server.registerTool`), the free-capped path
17
20
  * (`ctx.registerFree`), and the paid-tool path (`ctx.registerPayable`)
@@ -27,6 +30,41 @@
27
30
 
28
31
  const BODY_SNIPPET_MAX_CHARS = 500
29
32
 
33
+ /**
34
+ * `JSON.parse` reviver that returns the exact source text for integers
35
+ * `JSON.parse` would round. Relies on JSON.parse source access (V8 11.4+:
36
+ * workerd, Node 21+); on older runtimes `context` is undefined and values
37
+ * pass through unchanged.
38
+ */
39
+ function losslessIntReviver(_key: string, value: unknown, context?: { source?: string }): unknown {
40
+ if (
41
+ typeof value === 'number' &&
42
+ Number.isInteger(value) &&
43
+ !Number.isSafeInteger(value) &&
44
+ context?.source &&
45
+ /^-?\d+$/.test(context.source)
46
+ ) {
47
+ return context.source
48
+ }
49
+ return value
50
+ }
51
+
52
+ const rawJSON = (JSON as unknown as { rawJSON?: (text: string) => unknown }).rawJSON
53
+
54
+ /**
55
+ * `JSON.stringify` that writes digit strings back as bare JSON numbers for
56
+ * the given keys (the spec's integer properties), so int64 values returned
57
+ * as strings by `upstreamFetchJson` round-trip exactly. Keys not listed are
58
+ * left alone, so digit-only strings such as phone numbers stay strings.
59
+ */
60
+ export function stringifyLossless(body: unknown, integerKeys: ReadonlySet<string>): string {
61
+ return JSON.stringify(body, (key, value) =>
62
+ integerKeys.has(key) && typeof value === 'string' && /^-?\d+$/.test(value) && rawJSON
63
+ ? rawJSON(value)
64
+ : value,
65
+ )
66
+ }
67
+
30
68
  export interface UpstreamErrorInit {
31
69
  method: string
32
70
  url: string
@@ -100,7 +138,7 @@ export async function upstreamFetchJson<T = unknown>(
100
138
  }
101
139
 
102
140
  try {
103
- return JSON.parse(text) as T
141
+ return JSON.parse(text, losslessIntReviver as Parameters<typeof JSON.parse>[1]) as T
104
142
  } catch (err) {
105
143
  throw new UpstreamError({
106
144
  method,