@opensaas/stack-core 0.29.0 → 0.30.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.
@@ -3,11 +3,67 @@
3
3
  * Creates MCP API handlers from OpenSaaS config at runtime
4
4
  */
5
5
 
6
- import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
6
+ import * as z from 'zod'
7
+ import type { OpenSaasConfig, FieldConfig, McpCustomTool } from '../config/types.js'
7
8
  import type { AccessContext } from '../access/types.js'
8
9
  import { getDbKey } from '../lib/case-utils.js'
9
10
  import type { McpSession, McpSessionProvider } from './types.js'
10
11
 
12
+ /**
13
+ * Context session type accepted by the generated getContext factory.
14
+ * userId is always present; auth adapters may pass additional session fields
15
+ * (email, role, ...) through for access control.
16
+ */
17
+ type ContextSession = { userId: string; [key: string]: unknown }
18
+
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
+ */
25
+ function toContextSession(session: McpSession): ContextSession {
26
+ const { accessToken: _accessToken, expiresAt: _expiresAt, scopes: _scopes, ...rest } = session
27
+ return rest as ContextSession
28
+ }
29
+
30
+ /**
31
+ * MCP tools registered globally by plugins via `registerMcpTool`
32
+ * (stored by the plugin engine under `_pluginData.__mcpTools`).
33
+ */
34
+ function getPluginMcpTools(config: OpenSaasConfig): McpCustomTool[] {
35
+ return (config._pluginData?.__mcpTools as McpCustomTool[] | undefined) ?? []
36
+ }
37
+
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
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- duck-typing across zod instances
43
+ function isZodSchema(schema: any): schema is z.ZodType {
44
+ return !!schema && typeof schema.safeParse === 'function'
45
+ }
46
+
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
+ */
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inputSchema is user-supplied
53
+ function toolInputSchemaToJson(inputSchema: any): McpTool['inputSchema'] {
54
+ if (isZodSchema(inputSchema)) {
55
+ try {
56
+ const { $schema: _$schema, ...jsonSchema } = z.toJSONSchema(inputSchema)
57
+ return jsonSchema as McpTool['inputSchema']
58
+ } catch {
59
+ // Unconvertible schema (transforms, custom types) — advertise a
60
+ // permissive object schema; runtime validation still applies.
61
+ return { type: 'object', properties: {} }
62
+ }
63
+ }
64
+ return inputSchema as McpTool['inputSchema']
65
+ }
66
+
11
67
  /**
12
68
  * Create MCP route handlers
13
69
  *
@@ -32,7 +88,7 @@ import type { McpSession, McpSessionProvider } from './types.js'
32
88
  export function createMcpHandlers(options: {
33
89
  config: OpenSaasConfig
34
90
  getSession: McpSessionProvider
35
- getContext: (session?: { userId: string }) => Promise<AccessContext>
91
+ getContext: (session?: ContextSession) => Promise<AccessContext>
36
92
  }): {
37
93
  GET: (req: Request) => Promise<Response>
38
94
  POST: (req: Request) => Promise<Response>
@@ -378,12 +434,21 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
378
434
  tools.push({
379
435
  name: customTool.name,
380
436
  description: customTool.description,
381
- inputSchema: customTool.inputSchema,
437
+ inputSchema: toolInputSchemaToJson(customTool.inputSchema),
382
438
  })
383
439
  }
384
440
  }
385
441
  }
386
442
 
443
+ // Tools registered globally by plugins (e.g. the RAG plugin's semantic search)
444
+ for (const pluginTool of getPluginMcpTools(config)) {
445
+ tools.push({
446
+ name: pluginTool.name,
447
+ description: pluginTool.description,
448
+ inputSchema: toolInputSchemaToJson(pluginTool.inputSchema),
449
+ })
450
+ }
451
+
387
452
  return new Response(
388
453
  JSON.stringify({
389
454
  jsonrpc: '2.0',
@@ -404,7 +469,7 @@ async function handleToolsCall(
404
469
  params: any,
405
470
  session: McpSession,
406
471
  config: OpenSaasConfig,
407
- getContext: (session?: { userId: string }) => Promise<AccessContext>,
472
+ getContext: (session?: ContextSession) => Promise<AccessContext>,
408
473
  id?: number | string,
409
474
  ): Promise<Response> {
410
475
  const toolName = params?.name
@@ -446,11 +511,11 @@ async function handleCrudTool(
446
511
  args: any,
447
512
  session: McpSession,
448
513
  config: OpenSaasConfig,
449
- getContext: (session?: { userId: string }) => Promise<AccessContext>,
514
+ getContext: (session?: ContextSession) => Promise<AccessContext>,
450
515
  id?: number | string,
451
516
  ): Promise<Response> {
452
- // Create context with user session
453
- const context = await getContext({ userId: session.userId })
517
+ // Create context with the user session (custom session fields pass through)
518
+ const context = await getContext(toContextSession(session))
454
519
 
455
520
  try {
456
521
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Result type varies by Prisma operation
@@ -529,34 +594,59 @@ async function handleCustomTool(
529
594
  args: any,
530
595
  session: McpSession,
531
596
  config: OpenSaasConfig,
532
- getContext: (session?: { userId: string }) => Promise<AccessContext>,
597
+ getContext: (session?: ContextSession) => Promise<AccessContext>,
533
598
  id?: number | string,
534
599
  ): Promise<Response> {
535
- // Find custom tool in config
536
- for (const [_listKey, listConfig] of Object.entries(config.lists)) {
537
- const customTool = listConfig.mcp?.customTools?.find((t) => t.name === toolName)
538
-
539
- if (customTool) {
540
- const context = await getContext({ userId: session.userId })
600
+ // Find the tool: list-level custom tools first, then plugin-registered tools
601
+ let customTool: McpCustomTool | undefined
602
+ for (const listConfig of Object.values(config.lists)) {
603
+ customTool = listConfig.mcp?.customTools?.find((t) => t.name === toolName)
604
+ if (customTool) break
605
+ }
606
+ customTool ??= getPluginMcpTools(config).find((t) => t.name === toolName)
541
607
 
542
- try {
543
- const result = await customTool.handler({
544
- input: args,
545
- context,
546
- })
608
+ if (!customTool) {
609
+ return createErrorResponse(`Unknown tool: ${toolName}`, id)
610
+ }
547
611
 
548
- return createSuccessResponse(result, id)
549
- } catch (error) {
550
- return createErrorResponse(
551
- 'Custom tool execution failed: ' +
552
- (error instanceof Error ? error.message : 'Unknown error'),
553
- id,
554
- )
555
- }
612
+ // Validate input when the tool declares a Zod schema
613
+ let input = args
614
+ if (isZodSchema(customTool.inputSchema)) {
615
+ const parsed = customTool.inputSchema.safeParse(args)
616
+ if (!parsed.success) {
617
+ return new Response(
618
+ JSON.stringify({
619
+ jsonrpc: '2.0',
620
+ id: id ?? null,
621
+ error: {
622
+ code: -32602,
623
+ message: `Invalid params: ${parsed.error.message}`,
624
+ },
625
+ }),
626
+ {
627
+ status: 400,
628
+ headers: { 'Content-Type': 'application/json' },
629
+ },
630
+ )
556
631
  }
632
+ input = parsed.data
557
633
  }
558
634
 
559
- return createErrorResponse(`Unknown tool: ${toolName}`, id)
635
+ const context = await getContext(toContextSession(session))
636
+
637
+ try {
638
+ const result = await customTool.handler({
639
+ input,
640
+ context,
641
+ })
642
+
643
+ return createSuccessResponse(result, id)
644
+ } catch (error) {
645
+ return createErrorResponse(
646
+ 'Custom tool execution failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
647
+ id,
648
+ )
649
+ }
560
650
  }
561
651
 
562
652
  /**