@opensaas/stack-core 0.38.0 → 0.39.0

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.
Files changed (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -75,6 +75,22 @@ export { AccessScopeDepthExceededError } from './access/index.js'
75
75
  // from `ValidationError` for the same reason as `AccessScopeDepthExceededError`.
76
76
  export { ResolveOutputCycleError } from './access/index.js'
77
77
 
78
+ // Thrown by `checkFieldAccess` when a field-level access control function
79
+ // returns anything other than a strict boolean (see ADR-0001 and ADR-0030).
80
+ // Distinct from `ValidationError` for the same reason as
81
+ // `AccessScopeDepthExceededError` — this is the engine refusing to interpret
82
+ // a result it cannot treat as an allow/deny decision, not a user-input
83
+ // validation failure.
84
+ export { InvalidFieldAccessResultError } from './access/index.js'
85
+
86
+ // Thrown by a read when a caller-supplied `where` filters on a relation whose
87
+ // related list denies operation-level `query` access outright (see #916 and
88
+ // ADR-0022). Distinct from `ValidationError` for the same reason as
89
+ // `AccessScopeDepthExceededError` — this is the engine declining to return a
90
+ // silently-narrowed match on a relation it cannot scope, not a user-input
91
+ // validation failure.
92
+ export { RelationFilterAccessDeniedError } from './access/index.js'
93
+
78
94
  // Field self-containment validation — checks each field implements the
79
95
  // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
80
96
  // getPrismaRelation for relationships) so a misimplemented field fails early
@@ -127,6 +143,7 @@ export type {
127
143
  FilterValueSource,
128
144
  FilterFieldSuggestion,
129
145
  RelationshipCountFilterMarker,
146
+ FilterAccessArgs,
130
147
  } from './filter/index.js'
131
148
 
132
149
  // Access-scoped to-many relationship counts for the admin list view (#732):
@@ -139,10 +156,12 @@ export {
139
156
  isToManyRelationshipField,
140
157
  } from './access/relationship-count.js'
141
158
 
142
- // Access-scoped to-one relationship label filters for the admin list view
143
- // (#749): fold the related list's `query` access into a to-one relationship
144
- // Filter spec's nested `is` clause so a session can never use a relationship
145
- // filter token to distinguish rows by a related field it cannot itself read.
159
+ // To-one relationship label filter helpers for the admin list view (#749).
160
+ // `resolveRelationshipLabelFilters` is now a pass-through: the engine itself
161
+ // scopes every relation filter in `where` (`buildAccessScopedWhere`, #916),
162
+ // including the `{ is: {...} } }` shape a label filter produces, so this no
163
+ // longer needs its own access fold. Kept exported, unchanged in shape, for
164
+ // API compatibility — see `relationship-label-filter.ts`'s doc comment.
146
165
  export {
147
166
  resolveRelationshipLabelFilters,
148
167
  isToOneRelationshipField,
package/src/internal.ts CHANGED
@@ -41,6 +41,11 @@ export { validateWithZod, generateZodSchema } from './validation/schema.js'
41
41
  // This is the single field-access evaluator — the UI must not re-implement it.
42
42
  export { checkFieldAccess } from './access/index.js'
43
43
 
44
+ // Predicate-time field-read evaluator (#915), reused by @opensaas/stack-ui to
45
+ // keep the admin list view's sort validation in lockstep with the engine: a
46
+ // field the session cannot read cannot seed an `orderBy` either.
47
+ export { isFieldReadableForPredicate } from './access/index.js'
48
+
44
49
  // Config-shape sub-types consumed by sibling packages (not part of the consumer surface)
45
50
  export type {
46
51
  DatabaseConfig,
@@ -8,31 +8,16 @@
8
8
  * - URLs: kebab-case (e.g., "auth-user", "blog-post")
9
9
  */
10
10
 
11
- /**
12
- * Convert PascalCase to camelCase
13
- * AuthUser -> authUser
14
- * BlogPost -> blogPost
15
- */
16
11
  export function pascalToCamel(str: string): string {
17
12
  return str.charAt(0).toLowerCase() + str.slice(1)
18
13
  }
19
14
 
20
- /**
21
- * Convert PascalCase to kebab-case
22
- * AuthUser -> auth-user
23
- * BlogPost -> blog-post
24
- */
25
15
  export function pascalToKebab(str: string): string {
26
16
  return str.replace(/([A-Z])/g, (match, p1, offset) => {
27
17
  return offset > 0 ? `-${p1.toLowerCase()}` : p1.toLowerCase()
28
18
  })
29
19
  }
30
20
 
31
- /**
32
- * Convert kebab-case to PascalCase
33
- * auth-user -> AuthUser
34
- * blog-post -> BlogPost
35
- */
36
21
  export function kebabToPascal(str: string): string {
37
22
  return str
38
23
  .split('-')
@@ -40,35 +25,18 @@ export function kebabToPascal(str: string): string {
40
25
  .join('')
41
26
  }
42
27
 
43
- /**
44
- * Convert kebab-case to camelCase
45
- * auth-user -> authUser
46
- * blog-post -> blogPost
47
- */
48
28
  export function kebabToCamel(str: string): string {
49
29
  return str.replace(/-([a-z])/g, (match, p1) => p1.toUpperCase())
50
30
  }
51
31
 
52
- /**
53
- * Get the database key for a list (camelCase)
54
- * Used for accessing context.db and prisma client
55
- */
56
32
  export function getDbKey(listKey: string): string {
57
33
  return pascalToCamel(listKey)
58
34
  }
59
35
 
60
- /**
61
- * Get the URL segment for a list (kebab-case)
62
- * Used for constructing admin URLs
63
- */
64
36
  export function getUrlKey(listKey: string): string {
65
37
  return pascalToKebab(listKey)
66
38
  }
67
39
 
68
- /**
69
- * Get the list key from a URL segment (PascalCase)
70
- * Used for parsing admin URLs
71
- */
72
40
  export function getListKeyFromUrl(urlSegment: string): string {
73
41
  return kebabToPascal(urlSegment)
74
42
  }
@@ -1,8 +1,3 @@
1
- /**
2
- * Runtime MCP route handler
3
- * Creates MCP API handlers from OpenSaaS config at runtime
4
- */
5
-
6
1
  import * as z from 'zod'
7
2
  import type { OpenSaasConfig, FieldConfig, McpCustomTool } from '../config/types.js'
8
3
  import type { AccessContext } from '../access/types.js'
@@ -16,12 +11,7 @@ import type { McpSession, McpSessionProvider } from './types.js'
16
11
  */
17
12
  type ContextSession = { userId: string; [key: string]: unknown }
18
13
 
19
- /**
20
- * Convert an MCP session into a context session.
21
- * Transport-level fields (accessToken, expiresAt, scopes) are stripped;
22
- * everything else — userId plus any custom fields the session provider
23
- * attached (email, role, ...) — flows through to access control.
24
- */
14
+ /** Strips transport-level fields; userId and any custom session fields flow through to access control. */
25
15
  function toContextSession(session: McpSession): ContextSession {
26
16
  const { accessToken: _accessToken, expiresAt: _expiresAt, scopes: _scopes, ...rest } = session
27
17
  return rest as ContextSession
@@ -35,20 +25,12 @@ function getPluginMcpTools(config: OpenSaasConfig): McpCustomTool[] {
35
25
  return (config._pluginData?.__mcpTools as McpCustomTool[] | undefined) ?? []
36
26
  }
37
27
 
38
- /**
39
- * Whether a custom tool's inputSchema is a Zod schema (as opposed to a plain
40
- * JSON Schema object). Duck-typed so it works across zod module instances.
41
- */
42
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- duck-typing across zod instances
43
29
  function isZodSchema(schema: any): schema is z.ZodType {
44
30
  return !!schema && typeof schema.safeParse === 'function'
45
31
  }
46
32
 
47
- /**
48
- * Normalize a custom tool's inputSchema for the tools/list response.
49
- * Zod schemas are converted to JSON Schema (the MCP wire format); plain
50
- * objects are passed through as-is.
51
- */
33
+ /** Zod schemas are converted to JSON Schema (the MCP wire format); plain objects pass through as-is. */
52
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inputSchema is user-supplied
53
35
  function toolInputSchemaToJson(inputSchema: any): McpTool['inputSchema'] {
54
36
  if (isZodSchema(inputSchema)) {
@@ -96,7 +78,6 @@ export function createMcpHandlers(options: {
96
78
  } {
97
79
  const { config, getSession, getContext } = options
98
80
 
99
- // Validate MCP is enabled
100
81
  if (!config.mcp?.enabled) {
101
82
  const notEnabledHandler = async () =>
102
83
  new Response(JSON.stringify({ error: 'MCP not enabled' }), {
@@ -108,11 +89,7 @@ export function createMcpHandlers(options: {
108
89
 
109
90
  const basePath = config.mcp.basePath || '/api/mcp'
110
91
 
111
- /**
112
- * Main MCP request handler
113
- */
114
92
  const handler = async (req: Request): Promise<Response> => {
115
- // Authenticate using provided session provider
116
93
  const session = await getSession(req.headers)
117
94
  if (!session) {
118
95
  return new Response(null, {
@@ -132,23 +109,19 @@ export function createMcpHandlers(options: {
132
109
  params?: any
133
110
  }
134
111
 
135
- // Handle initialize
136
112
  if (body.method === 'initialize') {
137
113
  return handleInitialize(body.params, body.id)
138
114
  }
139
115
 
140
- // Handle notifications/initialized (sent by client after initialize response)
141
116
  if (body.method === 'notifications/initialized') {
142
117
  // Notifications don't require a response in JSON-RPC 2.0
143
118
  return new Response(null, { status: 204 })
144
119
  }
145
120
 
146
- // Handle tools/list
147
121
  if (body.method === 'tools/list') {
148
122
  return handleToolsList(config, body.id)
149
123
  }
150
124
 
151
- // Handle tools/call
152
125
  if (body.method === 'tools/call') {
153
126
  return await handleToolsCall(body.params, session, config, getContext, body.id)
154
127
  }
@@ -198,9 +171,6 @@ type McpTool = {
198
171
  }
199
172
  }
200
173
 
201
- /**
202
- * Handle initialize request - respond with server capabilities
203
- */
204
174
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Initialize params are from the client
205
175
  function handleInitialize(_params?: any, id?: number | string): Response {
206
176
  return new Response(
@@ -224,9 +194,6 @@ function handleInitialize(_params?: any, id?: number | string): Response {
224
194
  )
225
195
  }
226
196
 
227
- /**
228
- * Convert field config to JSON schema property
229
- */
230
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field configs have varying structures
231
198
  function fieldToJsonSchema(fieldName: string, fieldConfig: any): Record<string, unknown> {
232
199
  const baseSchema: Record<string, unknown> = {}
@@ -261,7 +228,6 @@ function fieldToJsonSchema(fieldName: string, fieldConfig: any): Record<string,
261
228
  }
262
229
  break
263
230
  case 'relationship':
264
- // For relationships, expect an ID or connect object
265
231
  baseSchema.type = 'object'
266
232
  baseSchema.properties = {
267
233
  connect: {
@@ -273,17 +239,12 @@ function fieldToJsonSchema(fieldName: string, fieldConfig: any): Record<string,
273
239
  }
274
240
  break
275
241
  default:
276
- // For custom field types, default to string
277
242
  baseSchema.type = 'string'
278
243
  }
279
244
 
280
245
  return baseSchema
281
246
  }
282
247
 
283
- /**
284
- * Generate field schemas for create/update operations
285
- */
286
-
287
248
  function generateFieldSchemas(
288
249
  fields: Record<string, FieldConfig>,
289
250
  operation: 'create' | 'update',
@@ -295,12 +256,10 @@ function generateFieldSchemas(
295
256
  const required: string[] = []
296
257
 
297
258
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
298
- // Skip system fields
299
259
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) continue
300
260
 
301
261
  properties[fieldName] = fieldToJsonSchema(fieldName, fieldConfig)
302
262
 
303
- // Add to required array if field is required for this operation
304
263
  if (
305
264
  operation === 'create' &&
306
265
  'validation' in fieldConfig &&
@@ -314,15 +273,10 @@ function generateFieldSchemas(
314
273
  return { properties, required }
315
274
  }
316
275
 
317
- /**
318
- * Handle tools/list request - list all available tools
319
- */
320
276
  function handleToolsList(config: OpenSaasConfig, id?: number | string): Response {
321
277
  const tools: McpTool[] = []
322
278
 
323
- // Generate CRUD tools for each list
324
279
  for (const [listKey, listConfig] of Object.entries(config.lists)) {
325
- // Check if MCP is enabled for this list
326
280
  if (listConfig.mcp?.enabled === false) continue
327
281
 
328
282
  const dbKey = getDbKey(listKey)
@@ -340,7 +294,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
340
294
  delete: listConfig.mcp?.tools?.delete ?? defaultTools.delete ?? true,
341
295
  }
342
296
 
343
- // Read tool
344
297
  if (enabledTools.read) {
345
298
  tools.push({
346
299
  name: `list_${dbKey}_query`,
@@ -357,7 +310,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
357
310
  })
358
311
  }
359
312
 
360
- // Create tool
361
313
  if (enabledTools.create) {
362
314
  const fieldSchemas = generateFieldSchemas(listConfig.fields, 'create')
363
315
  tools.push({
@@ -378,7 +330,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
378
330
  })
379
331
  }
380
332
 
381
- // Update tool
382
333
  if (enabledTools.update) {
383
334
  const fieldSchemas = generateFieldSchemas(listConfig.fields, 'update')
384
335
  tools.push({
@@ -406,7 +357,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
406
357
  })
407
358
  }
408
359
 
409
- // Delete tool
410
360
  if (enabledTools.delete) {
411
361
  tools.push({
412
362
  name: `list_${dbKey}_delete`,
@@ -428,7 +378,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
428
378
  })
429
379
  }
430
380
 
431
- // Custom tools
432
381
  if (listConfig.mcp?.customTools) {
433
382
  for (const customTool of listConfig.mcp.customTools) {
434
383
  tools.push({
@@ -461,9 +410,6 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
461
410
  )
462
411
  }
463
412
 
464
- /**
465
- * Handle tools/call request - execute a tool
466
- */
467
413
  async function handleToolsCall(
468
414
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP tool params vary by tool
469
415
  params: any,
@@ -489,7 +435,6 @@ async function handleToolsCall(
489
435
  )
490
436
  }
491
437
 
492
- // Parse tool name: list_{dbKey}_{operation}
493
438
  const match = toolName.match(/^list_([a-z][a-zA-Z0-9]*)_(query|create|update|delete)$/)
494
439
 
495
440
  if (match) {
@@ -497,13 +442,9 @@ async function handleToolsCall(
497
442
  return await handleCrudTool(dbKey, operation, toolArgs, session, config, getContext, id)
498
443
  }
499
444
 
500
- // Handle custom tools
501
445
  return await handleCustomTool(toolName, toolArgs, session, config, getContext, id)
502
446
  }
503
447
 
504
- /**
505
- * Handle CRUD tool execution
506
- */
507
448
  async function handleCrudTool(
508
449
  dbKey: string,
509
450
  operation: string,
@@ -514,7 +455,6 @@ async function handleCrudTool(
514
455
  getContext: (session?: ContextSession) => Promise<AccessContext>,
515
456
  id?: number | string,
516
457
  ): Promise<Response> {
517
- // Create context with the user session (custom session fields pass through)
518
458
  const context = await getContext(toContextSession(session))
519
459
 
520
460
  try {
@@ -585,9 +525,6 @@ async function handleCrudTool(
585
525
  }
586
526
  }
587
527
 
588
- /**
589
- * Handle custom tool execution
590
- */
591
528
  async function handleCustomTool(
592
529
  toolName: string,
593
530
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Custom tool arguments are user-defined
@@ -609,7 +546,6 @@ async function handleCustomTool(
609
546
  return createErrorResponse(`Unknown tool: ${toolName}`, id)
610
547
  }
611
548
 
612
- // Validate input when the tool declares a Zod schema
613
549
  let input = args
614
550
  if (isZodSchema(customTool.inputSchema)) {
615
551
  const parsed = customTool.inputSchema.safeParse(args)
@@ -650,8 +586,17 @@ async function handleCustomTool(
650
586
  }
651
587
 
652
588
  /**
653
- * Helper to create success response
589
+ * `JSON.stringify` replacer rendering a `bigint` (e.g. a `bigInt()` field's
590
+ * value) as a decimal string instead of throwing
591
+ * (`TypeError: Do not know how to serialize a BigInt`). A decimal string is
592
+ * the conventional JSON encoding of a 64-bit integer and is what an MCP
593
+ * client can consume — the field's TypeScript type stays `bigint` in
594
+ * application code (ADR-0029).
654
595
  */
596
+ function mcpJsonReplacer(_key: string, value: unknown): unknown {
597
+ return typeof value === 'bigint' ? value.toString() : value
598
+ }
599
+
655
600
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response data structure is flexible per MCP protocol
656
601
  function createSuccessResponse(data: any, id?: number | string): Response {
657
602
  return new Response(
@@ -659,7 +604,7 @@ function createSuccessResponse(data: any, id?: number | string): Response {
659
604
  jsonrpc: '2.0',
660
605
  id: id ?? null,
661
606
  result: {
662
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
607
+ content: [{ type: 'text', text: JSON.stringify(data, mcpJsonReplacer, 2) }],
663
608
  },
664
609
  }),
665
610
  {
@@ -668,9 +613,6 @@ function createSuccessResponse(data: any, id?: number | string): Response {
668
613
  )
669
614
  }
670
615
 
671
- /**
672
- * Helper to create error response
673
- */
674
616
  function createErrorResponse(message: string, id?: number | string): Response {
675
617
  return new Response(
676
618
  JSON.stringify({