@opensaas/stack-core 0.29.0 → 0.31.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +443 -0
- package/CLAUDE.md +4 -4
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +206 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/mcp/handler.d.ts +11 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +96 -20
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +286 -4
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/src/mcp/handler.ts +118 -28
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/mcp/handler.ts
CHANGED
|
@@ -3,11 +3,67 @@
|
|
|
3
3
|
* Creates MCP API handlers from OpenSaaS config at runtime
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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(
|
|
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?:
|
|
597
|
+
getContext: (session?: ContextSession) => Promise<AccessContext>,
|
|
533
598
|
id?: number | string,
|
|
534
599
|
): Promise<Response> {
|
|
535
|
-
// Find
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (customTool)
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
context,
|
|
546
|
-
})
|
|
608
|
+
if (!customTool) {
|
|
609
|
+
return createErrorResponse(`Unknown tool: ${toolName}`, id)
|
|
610
|
+
}
|
|
547
611
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
/**
|