@opensaas/stack-core 0.1.5 → 0.1.7

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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +5 -5
  4. package/dist/access/engine.d.ts +9 -3
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +6 -2
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/types.d.ts +1 -0
  9. package/dist/access/types.d.ts.map +1 -1
  10. package/dist/config/index.d.ts +6 -2
  11. package/dist/config/index.d.ts.map +1 -1
  12. package/dist/config/index.js +12 -2
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/config/plugin-engine.d.ts +25 -0
  15. package/dist/config/plugin-engine.d.ts.map +1 -0
  16. package/dist/config/plugin-engine.js +240 -0
  17. package/dist/config/plugin-engine.js.map +1 -0
  18. package/dist/config/types.d.ts +129 -0
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/context/index.d.ts +11 -1
  21. package/dist/context/index.d.ts.map +1 -1
  22. package/dist/context/index.js +116 -86
  23. package/dist/context/index.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp/handler.d.ts +1 -1
  28. package/dist/mcp/handler.d.ts.map +1 -1
  29. package/dist/mcp/handler.js +2 -2
  30. package/dist/mcp/handler.js.map +1 -1
  31. package/package.json +5 -1
  32. package/src/access/engine.ts +10 -5
  33. package/src/access/types.ts +1 -0
  34. package/src/config/index.ts +17 -2
  35. package/src/config/plugin-engine.ts +302 -0
  36. package/src/config/types.ts +147 -0
  37. package/src/context/index.ts +137 -95
  38. package/src/index.ts +4 -0
  39. package/src/mcp/handler.ts +6 -6
  40. package/tests/context.test.ts +13 -13
  41. package/tests/nested-access-and-hooks.test.ts +13 -13
  42. package/tests/password-type-distribution.test.ts +3 -3
  43. package/tests/password-types.test.ts +5 -5
  44. package/tests/sudo.test.ts +406 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -212,6 +212,27 @@ export type BaseFieldConfig<TInput = any, TOutput = TInput> = {
212
212
  type: string
213
213
  optional: boolean
214
214
  }
215
+ /**
216
+ * Get TypeScript imports needed for this field's type
217
+ * @returns Array of import statements needed for the generated types file
218
+ */
219
+ getTypeScriptImports?: () => Array<{
220
+ /**
221
+ * The type/value names to import
222
+ * e.g., ['StoredEmbedding', 'EmbeddingMetadata']
223
+ */
224
+ names: string[]
225
+ /**
226
+ * The module to import from
227
+ * e.g., '@opensaas/stack-rag'
228
+ */
229
+ from: string
230
+ /**
231
+ * Whether this is a type-only import
232
+ * @default true
233
+ */
234
+ typeOnly?: boolean
235
+ }>
215
236
  }
216
237
 
217
238
  export type TextField = BaseFieldConfig<string, string> & {
@@ -712,6 +733,121 @@ export interface ImageTransformationResult {
712
733
 
713
734
  export type StorageConfig = Record<string, { type: string; [key: string]: unknown }>
714
735
 
736
+ /**
737
+ * Plugin system types
738
+ */
739
+
740
+ /**
741
+ * Files generated by the core generators
742
+ * Plugins can modify these during afterGenerate hooks
743
+ */
744
+ export type GeneratedFiles = {
745
+ prismaSchema: string
746
+ types: string
747
+ context: string
748
+ [key: string]: string // Allow plugins to add custom generated files
749
+ }
750
+
751
+ /**
752
+ * Context provided to plugins during initialization
753
+ * Provides helpers for safely modifying config
754
+ */
755
+ export type PluginContext = {
756
+ /**
757
+ * Current config state (read-only)
758
+ * Plugins should use helper methods to modify config, not mutate directly
759
+ */
760
+ readonly config: OpenSaasConfig
761
+
762
+ /**
763
+ * Add a new list to the config
764
+ * Throws error if list already exists (unless merge strategy used)
765
+ */
766
+ addList: (name: string, listConfig: ListConfig) => void
767
+
768
+ /**
769
+ * Extend an existing list with additional fields, hooks, or access control
770
+ * Deep merges fields, hooks, and access control
771
+ * Throws error if list doesn't exist
772
+ */
773
+ extendList: (
774
+ name: string,
775
+ extension: {
776
+ fields?: Record<string, FieldConfig>
777
+ hooks?: Hooks
778
+ access?: {
779
+ operation?: OperationAccess
780
+ }
781
+ mcp?: ListMcpConfig
782
+ },
783
+ ) => void
784
+
785
+ /**
786
+ * Register a field type globally
787
+ * Useful for third-party field packages
788
+ */
789
+ registerFieldType?: (type: string, builder: (options?: unknown) => BaseFieldConfig) => void
790
+
791
+ /**
792
+ * Register a custom MCP tool
793
+ * Tools are added to the global MCP server
794
+ */
795
+ registerMcpTool?: (tool: McpCustomTool) => void
796
+
797
+ /**
798
+ * Store plugin-specific data in config for runtime access
799
+ * Prefixed with plugin name to avoid conflicts
800
+ */
801
+ setPluginData: <T>(pluginName: string, data: T) => void
802
+ }
803
+
804
+ /**
805
+ * Plugin definition
806
+ * Plugins can extend config, inject lists, add hooks, and participate in generation lifecycle
807
+ */
808
+ export type Plugin = {
809
+ /**
810
+ * Unique plugin name (used for dependency resolution and data storage)
811
+ */
812
+ name: string
813
+
814
+ /**
815
+ * Plugin version (semantic versioning)
816
+ */
817
+ version?: string
818
+
819
+ /**
820
+ * Dependencies on other plugins (by name)
821
+ * Ensures plugins execute in correct order
822
+ */
823
+ dependencies?: string[]
824
+
825
+ /**
826
+ * Main initialization hook
827
+ * Called during config processing to extend or modify configuration
828
+ */
829
+ init: (context: PluginContext) => void | Promise<void>
830
+
831
+ /**
832
+ * Optional: Modify config before Prisma schema generation
833
+ * Useful for programmatic config transformations
834
+ */
835
+ beforeGenerate?: (config: OpenSaasConfig) => OpenSaasConfig | Promise<OpenSaasConfig>
836
+
837
+ /**
838
+ * Optional: Post-process generated files
839
+ * Allows plugins to modify Prisma schema, types, or add custom generated files
840
+ */
841
+ afterGenerate?: (files: GeneratedFiles) => GeneratedFiles | Promise<GeneratedFiles>
842
+
843
+ /**
844
+ * Optional: Provide runtime services
845
+ * Called when creating context to provide plugin-specific services
846
+ * Return value is stored in context.plugins[pluginName]
847
+ */
848
+ runtime?: (context: import('../access/types.js').AccessContext) => unknown
849
+ }
850
+
715
851
  /**
716
852
  * Main configuration type
717
853
  */
@@ -734,4 +870,15 @@ export type OpenSaasConfig = {
734
870
  * @default ".opensaas"
735
871
  */
736
872
  opensaasPath?: string
873
+ /**
874
+ * Plugins to extend the stack
875
+ * Executed in array order (or dependency order if dependencies specified)
876
+ */
877
+ plugins?: Plugin[]
878
+ /**
879
+ * Plugin-specific data storage
880
+ * Keyed by plugin name, used for runtime configuration
881
+ * @internal
882
+ */
883
+ _pluginData?: Record<string, unknown>
737
884
  }
@@ -146,12 +146,23 @@ export function getContext<
146
146
  prisma: TPrisma,
147
147
  session: Session,
148
148
  storage?: StorageUtils,
149
+ _isSudo: boolean = false,
149
150
  ): {
150
151
  db: AccessControlledDB<TPrisma>
151
152
  session: Session
152
153
  prisma: TPrisma
153
154
  storage: StorageUtils
154
155
  serverAction: (props: ServerActionProps) => Promise<unknown>
156
+ _isSudo: boolean
157
+ sudo: () => {
158
+ db: AccessControlledDB<TPrisma>
159
+ session: Session
160
+ prisma: TPrisma
161
+ storage: StorageUtils
162
+ serverAction: (props: ServerActionProps) => Promise<unknown>
163
+ sudo: () => unknown
164
+ _isSudo: boolean
165
+ }
155
166
  } {
156
167
  // Initialize db object - will be populated with access-controlled operations
157
168
  // Type is intentionally broad to allow dynamic model access
@@ -185,6 +196,7 @@ export function getContext<
185
196
  )
186
197
  },
187
198
  },
199
+ _isSudo,
188
200
  }
189
201
 
190
202
  // Create access-controlled operations for each list
@@ -226,12 +238,20 @@ export function getContext<
226
238
  return null
227
239
  }
228
240
 
241
+ // Sudo function - creates a new context that bypasses access control
242
+ // but still executes all hooks and validation
243
+ function sudo() {
244
+ return getContext(config, prisma, session, context.storage, true)
245
+ }
246
+
229
247
  return {
230
248
  db: db as AccessControlledDB<TPrisma>,
231
249
  session,
232
250
  prisma,
233
251
  storage: context.storage,
234
252
  serverAction,
253
+ sudo,
254
+ _isSudo,
235
255
  }
236
256
  }
237
257
 
@@ -242,25 +262,29 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
242
262
  listName: string,
243
263
  listConfig: ListConfig,
244
264
  prisma: TPrisma,
245
- context: AccessContext,
265
+ context: AccessContext<TPrisma>,
246
266
  config: OpenSaasConfig,
247
267
  ) {
248
268
  return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
249
- // Check query access
250
- const queryAccess = listConfig.access?.operation?.query
251
- const accessResult = await checkAccess(queryAccess, {
252
- session: context.session,
253
- context,
254
- })
269
+ // Check query access (skip if sudo mode)
270
+ let where: Record<string, unknown> = args.where
271
+ if (!context._isSudo) {
272
+ const queryAccess = listConfig.access?.operation?.query
273
+ const accessResult = await checkAccess(queryAccess, {
274
+ session: context.session,
275
+ context,
276
+ })
255
277
 
256
- if (accessResult === false) {
257
- return null
258
- }
278
+ if (accessResult === false) {
279
+ return null
280
+ }
259
281
 
260
- // Merge access filter with where clause
261
- const where = mergeFilters(args.where, accessResult)
262
- if (where === null) {
263
- return null
282
+ // Merge access filter with where clause
283
+ const mergedWhere = mergeFilters(args.where, accessResult)
284
+ if (mergedWhere === null) {
285
+ return null
286
+ }
287
+ where = mergedWhere
264
288
  }
265
289
 
266
290
  // Build include with access control filters
@@ -290,12 +314,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
290
314
  }
291
315
 
292
316
  // Filter readable fields and apply resolveOutput hooks (including nested relationships)
317
+ // Pass sudo flag through context to skip field-level access checks
293
318
  const filtered = await filterReadableFields(
294
319
  item,
295
320
  listConfig.fields,
296
321
  {
297
322
  session: context.session,
298
- context,
323
+ context: { ...context, _isSudo: context._isSudo },
299
324
  },
300
325
  config,
301
326
  0,
@@ -323,7 +348,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
323
348
  listName: string,
324
349
  listConfig: ListConfig,
325
350
  prisma: TPrisma,
326
- context: AccessContext,
351
+ context: AccessContext<TPrisma>,
327
352
  config: OpenSaasConfig,
328
353
  ) {
329
354
  return async (args?: {
@@ -332,21 +357,25 @@ function createFindMany<TPrisma extends PrismaClientLike>(
332
357
  skip?: number
333
358
  include?: Record<string, unknown>
334
359
  }) => {
335
- // Check query access
336
- const queryAccess = listConfig.access?.operation?.query
337
- const accessResult = await checkAccess(queryAccess, {
338
- session: context.session,
339
- context,
340
- })
360
+ // Check query access (skip if sudo mode)
361
+ let where: Record<string, unknown> | undefined = args?.where
362
+ if (!context._isSudo) {
363
+ const queryAccess = listConfig.access?.operation?.query
364
+ const accessResult = await checkAccess(queryAccess, {
365
+ session: context.session,
366
+ context,
367
+ })
341
368
 
342
- if (accessResult === false) {
343
- return []
344
- }
369
+ if (accessResult === false) {
370
+ return []
371
+ }
345
372
 
346
- // Merge access filter with where clause
347
- const where = mergeFilters(args?.where, accessResult)
348
- if (where === null) {
349
- return []
373
+ // Merge access filter with where clause
374
+ const mergedWhere = mergeFilters(args?.where, accessResult)
375
+ if (mergedWhere === null) {
376
+ return []
377
+ }
378
+ where = mergedWhere
350
379
  }
351
380
 
352
381
  // Build include with access control filters
@@ -374,6 +403,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
374
403
  })
375
404
 
376
405
  // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
406
+ // Pass sudo flag through context to skip field-level access checks
377
407
  const filtered = await Promise.all(
378
408
  items.map((item: Record<string, unknown>) =>
379
409
  filterReadableFields(
@@ -381,7 +411,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
381
411
  listConfig.fields,
382
412
  {
383
413
  session: context.session,
384
- context,
414
+ context: { ...context, _isSudo: context._isSudo },
385
415
  },
386
416
  config,
387
417
  0,
@@ -415,19 +445,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
415
445
  listName: string,
416
446
  listConfig: ListConfig,
417
447
  prisma: TPrisma,
418
- context: AccessContext,
448
+ context: AccessContext<TPrisma>,
419
449
  config: OpenSaasConfig,
420
450
  ) {
421
451
  return async (args: { data: Record<string, unknown> }) => {
422
- // 1. Check create access
423
- const createAccess = listConfig.access?.operation?.create
424
- const accessResult = await checkAccess(createAccess, {
425
- session: context.session,
426
- context,
427
- })
452
+ // 1. Check create access (skip if sudo mode)
453
+ if (!context._isSudo) {
454
+ const createAccess = listConfig.access?.operation?.create
455
+ const accessResult = await checkAccess(createAccess, {
456
+ session: context.session,
457
+ context,
458
+ })
428
459
 
429
- if (accessResult === false) {
430
- return null
460
+ if (accessResult === false) {
461
+ return null
462
+ }
431
463
  }
432
464
 
433
465
  // 2. Execute list-level resolveInput hook
@@ -459,10 +491,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
459
491
  throw new ValidationError(validation.errors, validation.fieldErrors)
460
492
  }
461
493
 
462
- // 5. Filter writable fields (field-level access control)
494
+ // 5. Filter writable fields (field-level access control, skip if sudo mode)
463
495
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
464
496
  session: context.session,
465
- context,
497
+ context: { ...context, _isSudo: context._isSudo },
466
498
  })
467
499
 
468
500
  // 5.5. Process nested relationship operations
@@ -509,12 +541,13 @@ function createCreate<TPrisma extends PrismaClientLike>(
509
541
  )
510
542
 
511
543
  // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
544
+ // Pass sudo flag through context to skip field-level access checks
512
545
  const filtered = await filterReadableFields(
513
546
  item,
514
547
  listConfig.fields,
515
548
  {
516
549
  session: context.session,
517
- context,
550
+ context: { ...context, _isSudo: context._isSudo },
518
551
  },
519
552
  config,
520
553
  0,
@@ -532,7 +565,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
532
565
  listName: string,
533
566
  listConfig: ListConfig,
534
567
  prisma: TPrisma,
535
- context: AccessContext,
568
+ context: AccessContext<TPrisma>,
536
569
  config: OpenSaasConfig,
537
570
  ) {
538
571
  return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
@@ -548,27 +581,29 @@ function createUpdate<TPrisma extends PrismaClientLike>(
548
581
  return null
549
582
  }
550
583
 
551
- // 2. Check update access
552
- const updateAccess = listConfig.access?.operation?.update
553
- const accessResult = await checkAccess(updateAccess, {
554
- session: context.session,
555
- item,
556
- context,
557
- })
558
-
559
- if (accessResult === false) {
560
- return null
561
- }
562
-
563
- // If access returns a filter, check if item matches
564
- if (typeof accessResult === 'object') {
565
- const matchesFilter = await model.findFirst({
566
- where: mergeFilters(args.where, accessResult),
584
+ // 2. Check update access (skip if sudo mode)
585
+ if (!context._isSudo) {
586
+ const updateAccess = listConfig.access?.operation?.update
587
+ const accessResult = await checkAccess(updateAccess, {
588
+ session: context.session,
589
+ item,
590
+ context,
567
591
  })
568
592
 
569
- if (!matchesFilter) {
593
+ if (accessResult === false) {
570
594
  return null
571
595
  }
596
+
597
+ // If access returns a filter, check if item matches
598
+ if (typeof accessResult === 'object') {
599
+ const matchesFilter = await model.findFirst({
600
+ where: mergeFilters(args.where, accessResult),
601
+ })
602
+
603
+ if (!matchesFilter) {
604
+ return null
605
+ }
606
+ }
572
607
  }
573
608
 
574
609
  // 3. Execute list-level resolveInput hook
@@ -603,11 +638,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
603
638
  throw new ValidationError(validation.errors, validation.fieldErrors)
604
639
  }
605
640
 
606
- // 6. Filter writable fields (field-level access control)
641
+ // 6. Filter writable fields (field-level access control, skip if sudo mode)
607
642
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
608
643
  session: context.session,
609
644
  item,
610
- context,
645
+ context: { ...context, _isSudo: context._isSudo },
611
646
  })
612
647
 
613
648
  // 6.5. Process nested relationship operations
@@ -660,12 +695,13 @@ function createUpdate<TPrisma extends PrismaClientLike>(
660
695
  )
661
696
 
662
697
  // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
698
+ // Pass sudo flag through context to skip field-level access checks
663
699
  const filtered = await filterReadableFields(
664
700
  updated,
665
701
  listConfig.fields,
666
702
  {
667
703
  session: context.session,
668
- context,
704
+ context: { ...context, _isSudo: context._isSudo },
669
705
  },
670
706
  config,
671
707
  0,
@@ -683,7 +719,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
683
719
  listName: string,
684
720
  listConfig: ListConfig,
685
721
  prisma: TPrisma,
686
- context: AccessContext,
722
+ context: AccessContext<TPrisma>,
687
723
  ) {
688
724
  return async (args: { where: { id: string } }) => {
689
725
  // 1. Fetch the item to pass to access control and hooks
@@ -698,27 +734,29 @@ function createDelete<TPrisma extends PrismaClientLike>(
698
734
  return null
699
735
  }
700
736
 
701
- // 2. Check delete access
702
- const deleteAccess = listConfig.access?.operation?.delete
703
- const accessResult = await checkAccess(deleteAccess, {
704
- session: context.session,
705
- item,
706
- context,
707
- })
708
-
709
- if (accessResult === false) {
710
- return null
711
- }
712
-
713
- // If access returns a filter, check if item matches
714
- if (typeof accessResult === 'object') {
715
- const matchesFilter = await model.findFirst({
716
- where: mergeFilters(args.where, accessResult),
737
+ // 2. Check delete access (skip if sudo mode)
738
+ if (!context._isSudo) {
739
+ const deleteAccess = listConfig.access?.operation?.delete
740
+ const accessResult = await checkAccess(deleteAccess, {
741
+ session: context.session,
742
+ item,
743
+ context,
717
744
  })
718
745
 
719
- if (!matchesFilter) {
746
+ if (accessResult === false) {
720
747
  return null
721
748
  }
749
+
750
+ // If access returns a filter, check if item matches
751
+ if (typeof accessResult === 'object') {
752
+ const matchesFilter = await model.findFirst({
753
+ where: mergeFilters(args.where, accessResult),
754
+ })
755
+
756
+ if (!matchesFilter) {
757
+ return null
758
+ }
759
+ }
722
760
  }
723
761
 
724
762
  // 3. Execute field-level beforeOperation hooks (side effects only)
@@ -764,24 +802,28 @@ function createCount<TPrisma extends PrismaClientLike>(
764
802
  listName: string,
765
803
  listConfig: ListConfig,
766
804
  prisma: TPrisma,
767
- context: AccessContext,
805
+ context: AccessContext<TPrisma>,
768
806
  ) {
769
807
  return async (args?: { where?: Record<string, unknown> }) => {
770
- // Check query access
771
- const queryAccess = listConfig.access?.operation?.query
772
- const accessResult = await checkAccess(queryAccess, {
773
- session: context.session,
774
- context,
775
- })
808
+ // Check query access (skip if sudo mode)
809
+ let where: Record<string, unknown> | undefined = args?.where
810
+ if (!context._isSudo) {
811
+ const queryAccess = listConfig.access?.operation?.query
812
+ const accessResult = await checkAccess(queryAccess, {
813
+ session: context.session,
814
+ context,
815
+ })
776
816
 
777
- if (accessResult === false) {
778
- return 0
779
- }
817
+ if (accessResult === false) {
818
+ return 0
819
+ }
780
820
 
781
- // Merge access filter with where clause
782
- const where = mergeFilters(args?.where, accessResult)
783
- if (where === null) {
784
- return 0
821
+ // Merge access filter with where clause
822
+ const mergedWhere = mergeFilters(args?.where, accessResult)
823
+ if (mergedWhere === null) {
824
+ return 0
825
+ }
826
+ where = mergedWhere
785
827
  }
786
828
 
787
829
  // Execute count
package/src/index.ts CHANGED
@@ -24,6 +24,10 @@ export type {
24
24
  FileMetadata,
25
25
  ImageMetadata,
26
26
  ImageTransformationResult,
27
+ // Plugin system types
28
+ Plugin,
29
+ PluginContext,
30
+ GeneratedFiles,
27
31
  } from './config/index.js'
28
32
 
29
33
  // Access control
@@ -32,7 +32,7 @@ import type { McpSession, McpSessionProvider } from './types.js'
32
32
  export function createMcpHandlers(options: {
33
33
  config: OpenSaasConfig
34
34
  getSession: McpSessionProvider
35
- getContext: (session?: { userId: string }) => AccessContext
35
+ getContext: (session?: { userId: string }) => Promise<AccessContext>
36
36
  }): {
37
37
  GET: (req: Request) => Promise<Response>
38
38
  POST: (req: Request) => Promise<Response>
@@ -403,7 +403,7 @@ async function handleToolsCall(
403
403
  params: any,
404
404
  session: McpSession,
405
405
  config: OpenSaasConfig,
406
- getContext: (session?: { userId: string }) => AccessContext,
406
+ getContext: (session?: { userId: string }) => Promise<AccessContext>,
407
407
  id?: number | string,
408
408
  ): Promise<Response> {
409
409
  const toolName = params?.name
@@ -447,11 +447,11 @@ async function handleCrudTool(
447
447
  args: any,
448
448
  session: McpSession,
449
449
  config: OpenSaasConfig,
450
- getContext: (session?: { userId: string }) => AccessContext,
450
+ getContext: (session?: { userId: string }) => Promise<AccessContext>,
451
451
  id?: number | string,
452
452
  ): Promise<Response> {
453
453
  // Create context with user session
454
- const context = getContext({ userId: session.userId })
454
+ const context = await getContext({ userId: session.userId })
455
455
 
456
456
  try {
457
457
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Result type varies by Prisma operation
@@ -530,7 +530,7 @@ async function handleCustomTool(
530
530
  args: any,
531
531
  session: McpSession,
532
532
  config: OpenSaasConfig,
533
- getContext: (session?: { userId: string }) => AccessContext,
533
+ getContext: (session?: { userId: string }) => Promise<AccessContext>,
534
534
  id?: number | string,
535
535
  ): Promise<Response> {
536
536
  // Find custom tool in config
@@ -538,7 +538,7 @@ async function handleCustomTool(
538
538
  const customTool = listConfig.mcp?.customTools?.find((t) => t.name === toolName)
539
539
 
540
540
  if (customTool) {
541
- const context = getContext({ userId: session.userId })
541
+ const context = await getContext({ userId: session.userId })
542
542
 
543
543
  try {
544
544
  const result = await customTool.handler({