@opensaas/stack-core 0.39.2 → 0.40.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 +150 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +76 -10
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +76 -10
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/mcp/handler.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import * as z from 'zod'
|
|
2
|
-
import type { OpenSaasConfig,
|
|
2
|
+
import type { OpenSaasConfig, McpCustomTool } from '../config/types.js'
|
|
3
3
|
import type { AccessContext } from '../access/types.js'
|
|
4
|
+
import { checkAccess } from '../access/engine.js'
|
|
5
|
+
import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from '../access/errors.js'
|
|
6
|
+
import { ValidationError } from '../hooks/index.js'
|
|
4
7
|
import { getDbKey } from '../lib/case-utils.js'
|
|
5
8
|
import type { McpSession, McpSessionProvider } from './types.js'
|
|
9
|
+
import { generateFieldSchemas } from './field-schema.js'
|
|
10
|
+
import {
|
|
11
|
+
McpProjectionRefusedError,
|
|
12
|
+
generateFieldsProjectionSchema,
|
|
13
|
+
projectMcpResult,
|
|
14
|
+
resolveFieldsProjection,
|
|
15
|
+
} from './projection.js'
|
|
6
16
|
|
|
7
17
|
/**
|
|
8
18
|
* Context session type accepted by the generated getContext factory.
|
|
@@ -119,7 +129,8 @@ export function createMcpHandlers(options: {
|
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
if (body.method === 'tools/list') {
|
|
122
|
-
|
|
132
|
+
const context = await getContext(toContextSession(session))
|
|
133
|
+
return await handleToolsList(config, context, body.id)
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
if (body.method === 'tools/call') {
|
|
@@ -194,91 +205,24 @@ function handleInitialize(_params?: any, id?: number | string): Response {
|
|
|
194
205
|
)
|
|
195
206
|
}
|
|
196
207
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
case 'text':
|
|
203
|
-
case 'password':
|
|
204
|
-
baseSchema.type = 'string'
|
|
205
|
-
if (fieldConfig.validation?.length) {
|
|
206
|
-
if (fieldConfig.validation.length.min)
|
|
207
|
-
baseSchema.minLength = fieldConfig.validation.length.min
|
|
208
|
-
if (fieldConfig.validation.length.max)
|
|
209
|
-
baseSchema.maxLength = fieldConfig.validation.length.max
|
|
210
|
-
}
|
|
211
|
-
break
|
|
212
|
-
case 'integer':
|
|
213
|
-
baseSchema.type = 'number'
|
|
214
|
-
if (fieldConfig.validation?.min !== undefined) baseSchema.minimum = fieldConfig.validation.min
|
|
215
|
-
if (fieldConfig.validation?.max !== undefined) baseSchema.maximum = fieldConfig.validation.max
|
|
216
|
-
break
|
|
217
|
-
case 'checkbox':
|
|
218
|
-
baseSchema.type = 'boolean'
|
|
219
|
-
break
|
|
220
|
-
case 'timestamp':
|
|
221
|
-
baseSchema.type = 'string'
|
|
222
|
-
baseSchema.format = 'date-time'
|
|
223
|
-
break
|
|
224
|
-
case 'select':
|
|
225
|
-
baseSchema.type = 'string'
|
|
226
|
-
if (fieldConfig.options) {
|
|
227
|
-
baseSchema.enum = fieldConfig.options.map((opt: { value: string }) => opt.value)
|
|
228
|
-
}
|
|
229
|
-
break
|
|
230
|
-
case 'relationship':
|
|
231
|
-
baseSchema.type = 'object'
|
|
232
|
-
baseSchema.properties = {
|
|
233
|
-
connect: {
|
|
234
|
-
type: 'object',
|
|
235
|
-
properties: {
|
|
236
|
-
id: { type: 'string' },
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
}
|
|
240
|
-
break
|
|
241
|
-
default:
|
|
242
|
-
baseSchema.type = 'string'
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return baseSchema
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function generateFieldSchemas(
|
|
249
|
-
fields: Record<string, FieldConfig>,
|
|
250
|
-
operation: 'create' | 'update',
|
|
251
|
-
): {
|
|
252
|
-
properties: Record<string, unknown>
|
|
253
|
-
required: string[]
|
|
254
|
-
} {
|
|
255
|
-
const properties: Record<string, unknown> = {}
|
|
256
|
-
const required: string[] = []
|
|
257
|
-
|
|
258
|
-
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
259
|
-
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) continue
|
|
260
|
-
|
|
261
|
-
properties[fieldName] = fieldToJsonSchema(fieldName, fieldConfig)
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
operation === 'create' &&
|
|
265
|
-
'validation' in fieldConfig &&
|
|
266
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validation property varies by field type
|
|
267
|
-
(fieldConfig.validation as any)?.isRequired
|
|
268
|
-
) {
|
|
269
|
-
required.push(fieldName)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return { properties, required }
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function handleToolsList(config: OpenSaasConfig, id?: number | string): Response {
|
|
208
|
+
async function handleToolsList(
|
|
209
|
+
config: OpenSaasConfig,
|
|
210
|
+
context: AccessContext,
|
|
211
|
+
id?: number | string,
|
|
212
|
+
): Promise<Response> {
|
|
277
213
|
const tools: McpTool[] = []
|
|
278
214
|
|
|
279
215
|
for (const [listKey, listConfig] of Object.entries(config.lists)) {
|
|
280
216
|
if (listConfig.mcp?.enabled === false) continue
|
|
281
217
|
|
|
218
|
+
// A session denied operation-level `query` outright sees none of this
|
|
219
|
+
// list's tools, nor any relation entry elsewhere pointing at it as a
|
|
220
|
+
// target (`relatedListIfVisible` in projection.ts applies the identical
|
|
221
|
+
// check there) — ADR-0033.
|
|
222
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
223
|
+
const accessResult = await checkAccess(queryAccess, { session: context.session, context })
|
|
224
|
+
if (accessResult === false) continue
|
|
225
|
+
|
|
282
226
|
const dbKey = getDbKey(listKey)
|
|
283
227
|
const defaultTools = config.mcp?.defaultTools || {
|
|
284
228
|
read: true,
|
|
@@ -295,6 +239,12 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
|
|
|
295
239
|
}
|
|
296
240
|
|
|
297
241
|
if (enabledTools.read) {
|
|
242
|
+
const fieldsSchema = await generateFieldsProjectionSchema(
|
|
243
|
+
listConfig,
|
|
244
|
+
config,
|
|
245
|
+
context.session,
|
|
246
|
+
context,
|
|
247
|
+
)
|
|
298
248
|
tools.push({
|
|
299
249
|
name: `list_${dbKey}_query`,
|
|
300
250
|
description: `Query ${listKey} records with optional filters`,
|
|
@@ -305,6 +255,7 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
|
|
|
305
255
|
take: { type: 'number', description: 'Number of records to return (max 100)' },
|
|
306
256
|
skip: { type: 'number', description: 'Number of records to skip' },
|
|
307
257
|
orderBy: { type: 'object', description: 'Sort order' },
|
|
258
|
+
fields: fieldsSchema,
|
|
308
259
|
},
|
|
309
260
|
},
|
|
310
261
|
})
|
|
@@ -462,27 +413,60 @@ async function handleCrudTool(
|
|
|
462
413
|
let result: any
|
|
463
414
|
|
|
464
415
|
switch (operation) {
|
|
465
|
-
case 'query':
|
|
416
|
+
case 'query': {
|
|
417
|
+
let projection: Awaited<ReturnType<typeof resolveFieldsProjection>> | undefined
|
|
418
|
+
if (args.fields !== undefined) {
|
|
419
|
+
const listEntry = Object.entries(config.lists).find(
|
|
420
|
+
([listKey]) => getDbKey(listKey) === dbKey,
|
|
421
|
+
)
|
|
422
|
+
if (!listEntry) {
|
|
423
|
+
return createErrorResponse(`Unknown list for tool: ${dbKey}`, id)
|
|
424
|
+
}
|
|
425
|
+
const [listKey, listConfig] = listEntry
|
|
426
|
+
try {
|
|
427
|
+
projection = await resolveFieldsProjection(
|
|
428
|
+
args.fields,
|
|
429
|
+
listKey,
|
|
430
|
+
listConfig,
|
|
431
|
+
config,
|
|
432
|
+
context.session,
|
|
433
|
+
context,
|
|
434
|
+
)
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error instanceof McpProjectionRefusedError || error instanceof ValidationError) {
|
|
437
|
+
return createErrorResultResponse(error.message, id)
|
|
438
|
+
}
|
|
439
|
+
throw error
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
466
443
|
result = await context.db[dbKey].findMany({
|
|
467
444
|
where: args.where,
|
|
468
445
|
take: Math.min(args.take || 10, 100),
|
|
469
446
|
skip: args.skip,
|
|
470
447
|
orderBy: args.orderBy,
|
|
448
|
+
...(projection?.include ? { include: projection.include } : {}),
|
|
471
449
|
})
|
|
450
|
+
|
|
451
|
+
const items = projection
|
|
452
|
+
? result.map((item: Record<string, unknown>) => projectMcpResult(item, projection))
|
|
453
|
+
: result
|
|
454
|
+
|
|
472
455
|
return createSuccessResponse(
|
|
473
456
|
{
|
|
474
|
-
items
|
|
475
|
-
count:
|
|
457
|
+
items,
|
|
458
|
+
count: items.length,
|
|
476
459
|
},
|
|
477
460
|
id,
|
|
478
461
|
)
|
|
462
|
+
}
|
|
479
463
|
|
|
480
464
|
case 'create':
|
|
481
465
|
result = await context.db[dbKey].create({
|
|
482
466
|
data: args.data,
|
|
483
467
|
})
|
|
484
468
|
if (!result) {
|
|
485
|
-
return
|
|
469
|
+
return createErrorResultResponse(
|
|
486
470
|
'Failed to create record. Access denied or validation failed.',
|
|
487
471
|
id,
|
|
488
472
|
)
|
|
@@ -495,7 +479,7 @@ async function handleCrudTool(
|
|
|
495
479
|
data: args.data,
|
|
496
480
|
})
|
|
497
481
|
if (!result) {
|
|
498
|
-
return
|
|
482
|
+
return createErrorResultResponse(
|
|
499
483
|
'Failed to update record. Access denied or record not found.',
|
|
500
484
|
id,
|
|
501
485
|
)
|
|
@@ -507,7 +491,7 @@ async function handleCrudTool(
|
|
|
507
491
|
where: args.where,
|
|
508
492
|
})
|
|
509
493
|
if (!result) {
|
|
510
|
-
return
|
|
494
|
+
return createErrorResultResponse(
|
|
511
495
|
'Failed to delete record. Access denied or record not found.',
|
|
512
496
|
id,
|
|
513
497
|
)
|
|
@@ -518,7 +502,13 @@ async function handleCrudTool(
|
|
|
518
502
|
return createErrorResponse(`Unknown operation: ${operation}`, id)
|
|
519
503
|
}
|
|
520
504
|
} catch (error) {
|
|
521
|
-
|
|
505
|
+
if (
|
|
506
|
+
error instanceof AccessScopeDepthExceededError ||
|
|
507
|
+
error instanceof RelationFilterAccessDeniedError
|
|
508
|
+
) {
|
|
509
|
+
return createErrorResultResponse(error.message, id)
|
|
510
|
+
}
|
|
511
|
+
return createErrorResultResponse(
|
|
522
512
|
'Operation failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
|
523
513
|
id,
|
|
524
514
|
)
|
|
@@ -550,20 +540,7 @@ async function handleCustomTool(
|
|
|
550
540
|
if (isZodSchema(customTool.inputSchema)) {
|
|
551
541
|
const parsed = customTool.inputSchema.safeParse(args)
|
|
552
542
|
if (!parsed.success) {
|
|
553
|
-
return
|
|
554
|
-
JSON.stringify({
|
|
555
|
-
jsonrpc: '2.0',
|
|
556
|
-
id: id ?? null,
|
|
557
|
-
error: {
|
|
558
|
-
code: -32602,
|
|
559
|
-
message: `Invalid params: ${parsed.error.message}`,
|
|
560
|
-
},
|
|
561
|
-
}),
|
|
562
|
-
{
|
|
563
|
-
status: 400,
|
|
564
|
-
headers: { 'Content-Type': 'application/json' },
|
|
565
|
-
},
|
|
566
|
-
)
|
|
543
|
+
return createErrorResultResponse(`Invalid params: ${parsed.error.message}`, id)
|
|
567
544
|
}
|
|
568
545
|
input = parsed.data
|
|
569
546
|
}
|
|
@@ -578,7 +555,7 @@ async function handleCustomTool(
|
|
|
578
555
|
|
|
579
556
|
return createSuccessResponse(result, id)
|
|
580
557
|
} catch (error) {
|
|
581
|
-
return
|
|
558
|
+
return createErrorResultResponse(
|
|
582
559
|
'Custom tool execution failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
|
583
560
|
id,
|
|
584
561
|
)
|
|
@@ -597,15 +574,39 @@ function mcpJsonReplacer(_key: string, value: unknown): unknown {
|
|
|
597
574
|
return typeof value === 'bigint' ? value.toString() : value
|
|
598
575
|
}
|
|
599
576
|
|
|
577
|
+
function toToolContent(data: unknown): { type: 'text'; text: string }[] {
|
|
578
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data, mcpJsonReplacer, 2)
|
|
579
|
+
return [{ type: 'text', text }]
|
|
580
|
+
}
|
|
581
|
+
|
|
600
582
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response data structure is flexible per MCP protocol
|
|
601
583
|
function createSuccessResponse(data: any, id?: number | string): Response {
|
|
602
584
|
return new Response(
|
|
603
585
|
JSON.stringify({
|
|
604
586
|
jsonrpc: '2.0',
|
|
605
587
|
id: id ?? null,
|
|
606
|
-
result: {
|
|
607
|
-
|
|
608
|
-
|
|
588
|
+
result: { content: toToolContent(data) },
|
|
589
|
+
}),
|
|
590
|
+
{
|
|
591
|
+
headers: { 'Content-Type': 'application/json' },
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* A dispatched tool that failed — access denial, a thrown engine/database
|
|
598
|
+
* error, or custom-tool input validation. This is a successful JSON-RPC
|
|
599
|
+
* response (HTTP 200, no `error` member): the model that called the tool
|
|
600
|
+
* needs to see the failure in `result` to have any chance of correcting its
|
|
601
|
+
* request, whereas a JSON-RPC `error` object is a protocol-level failure a
|
|
602
|
+
* host may never surface back to it.
|
|
603
|
+
*/
|
|
604
|
+
function createErrorResultResponse(message: string, id?: number | string): Response {
|
|
605
|
+
return new Response(
|
|
606
|
+
JSON.stringify({
|
|
607
|
+
jsonrpc: '2.0',
|
|
608
|
+
id: id ?? null,
|
|
609
|
+
result: { content: toToolContent(message), isError: true },
|
|
609
610
|
}),
|
|
610
611
|
{
|
|
611
612
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -613,6 +614,7 @@ function createSuccessResponse(data: any, id?: number | string): Response {
|
|
|
613
614
|
)
|
|
614
615
|
}
|
|
615
616
|
|
|
617
|
+
/** Genuine JSON-RPC protocol failures — unknown method, malformed request, unknown tool name. */
|
|
616
618
|
function createErrorResponse(message: string, id?: number | string): Response {
|
|
617
619
|
return new Response(
|
|
618
620
|
JSON.stringify({
|