@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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,14 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import * as z from 'zod'
2
3
  import { createMcpHandlers } from '../src/mcp/handler.js'
3
4
  import type { OpenSaasConfig } from '../src/config/types.js'
4
5
  import type { AccessContext } from '../src/access/types.js'
5
6
  import type { McpSession, McpSessionProvider } from '../src/mcp/types.js'
7
+ import { HashedPassword } from '../src/utils/password.js'
8
+ import {
9
+ AccessScopeDepthExceededError,
10
+ RelationFilterAccessDeniedError,
11
+ } from '../src/access/errors.js'
6
12
 
7
13
  describe('MCP Handler', () => {
8
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -360,6 +366,41 @@ describe('MCP Handler', () => {
360
366
  expect(result.items[0].occurredAtMs).toBe('9007199254740993')
361
367
  })
362
368
 
369
+ // Regression test (issue #965): the response's JSON.stringify replacer runs
370
+ // AFTER toJSON, so it can't intercept a HashedPassword that leaks its hash
371
+ // from toJSON — the redaction has to live in HashedPassword itself.
372
+ it('should not leak a bcrypt hash for a password field in the query response', async () => {
373
+ const hash = '$2b$04$q5yvruv7NaUYCQvV.UGzf.92.dpteicGq5MoSHYNWqzIm.bsIIJrW'
374
+ const mockResults = [{ id: '1', title: 'Post 1', password: new HashedPassword(hash) }]
375
+ mockPrisma.post.findMany.mockResolvedValue(mockResults)
376
+
377
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
378
+
379
+ const request = new Request('http://localhost/api/mcp', {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify({
383
+ jsonrpc: '2.0',
384
+ id: 1,
385
+ method: 'tools/call',
386
+ params: {
387
+ name: 'list_post_query',
388
+ arguments: { where: {}, take: 10 },
389
+ },
390
+ }),
391
+ })
392
+
393
+ const response = await handlers.POST(request)
394
+ expect(response.status).toBe(200)
395
+
396
+ const data = await response.json()
397
+ const responseBody = data.result.content[0].text
398
+ expect(responseBody).not.toContain(hash)
399
+
400
+ const result = JSON.parse(responseBody)
401
+ expect(result.items[0].password).toEqual({ isSet: true })
402
+ })
403
+
363
404
  it('should execute create operation', async () => {
364
405
  const mockResult = { id: '1', title: 'New Post', content: 'New Content' }
365
406
  mockPrisma.post.create.mockResolvedValue(mockResult)
@@ -510,10 +551,12 @@ describe('MCP Handler', () => {
510
551
  })
511
552
 
512
553
  const response = await handlers.POST(request)
513
- expect(response.status).toBe(400)
554
+ expect(response.status).toBe(200)
514
555
 
515
556
  const data = await response.json()
516
- expect(data.error.message).toContain('Access denied')
557
+ expect(data.error).toBeUndefined()
558
+ expect(data.result.isError).toBe(true)
559
+ expect(data.result.content[0].text).toContain('Access denied')
517
560
  })
518
561
  })
519
562
 
@@ -601,6 +644,109 @@ describe('MCP Handler', () => {
601
644
  const data = await response.json()
602
645
  expect(data.error.message).toContain('Unknown tool')
603
646
  })
647
+
648
+ it('should return a custom tool input schema validation failure as a tool result', async () => {
649
+ const configWithCustomTools = {
650
+ ...config,
651
+ lists: {
652
+ Post: {
653
+ ...config.lists.Post,
654
+ mcp: {
655
+ customTools: [
656
+ {
657
+ name: 'publishPost',
658
+ description: 'Publish a post',
659
+ inputSchema: z.object({ postId: z.string() }),
660
+ handler: vi.fn(),
661
+ },
662
+ ],
663
+ },
664
+ },
665
+ },
666
+ }
667
+
668
+ const handlers = createMcpHandlers({
669
+ config: configWithCustomTools,
670
+ getSession: mockGetSession,
671
+ getContext,
672
+ })
673
+
674
+ const request = new Request('http://localhost/api/mcp', {
675
+ method: 'POST',
676
+ headers: { 'Content-Type': 'application/json' },
677
+ body: JSON.stringify({
678
+ jsonrpc: '2.0',
679
+ id: 1,
680
+ method: 'tools/call',
681
+ params: {
682
+ name: 'publishPost',
683
+ arguments: {},
684
+ },
685
+ }),
686
+ })
687
+
688
+ const response = await handlers.POST(request)
689
+ expect(response.status).toBe(200)
690
+
691
+ const data = await response.json()
692
+ expect(data.error).toBeUndefined()
693
+ expect(data.result.isError).toBe(true)
694
+ expect(data.result.content[0].text).toContain('Invalid params')
695
+ })
696
+
697
+ it('should return a thrown custom tool error as a tool result', async () => {
698
+ const configWithCustomTools = {
699
+ ...config,
700
+ lists: {
701
+ Post: {
702
+ ...config.lists.Post,
703
+ mcp: {
704
+ customTools: [
705
+ {
706
+ name: 'publishPost',
707
+ description: 'Publish a post',
708
+ inputSchema: {
709
+ type: 'object' as const,
710
+ properties: { postId: { type: 'string' } },
711
+ },
712
+ handler: vi.fn(async () => {
713
+ throw new Error('cannot publish an already-published post')
714
+ }),
715
+ },
716
+ ],
717
+ },
718
+ },
719
+ },
720
+ }
721
+
722
+ const handlers = createMcpHandlers({
723
+ config: configWithCustomTools,
724
+ getSession: mockGetSession,
725
+ getContext,
726
+ })
727
+
728
+ const request = new Request('http://localhost/api/mcp', {
729
+ method: 'POST',
730
+ headers: { 'Content-Type': 'application/json' },
731
+ body: JSON.stringify({
732
+ jsonrpc: '2.0',
733
+ id: 1,
734
+ method: 'tools/call',
735
+ params: {
736
+ name: 'publishPost',
737
+ arguments: { postId: 'post-123' },
738
+ },
739
+ }),
740
+ })
741
+
742
+ const response = await handlers.POST(request)
743
+ expect(response.status).toBe(200)
744
+
745
+ const data = await response.json()
746
+ expect(data.error).toBeUndefined()
747
+ expect(data.result.isError).toBe(true)
748
+ expect(data.result.content[0].text).toContain('cannot publish an already-published post')
749
+ })
604
750
  })
605
751
 
606
752
  describe('error handling', () => {
@@ -668,10 +814,74 @@ describe('MCP Handler', () => {
668
814
  })
669
815
 
670
816
  const response = await handlers.POST(request)
671
- expect(response.status).toBe(400)
817
+ expect(response.status).toBe(200)
818
+
819
+ const data = await response.json()
820
+ expect(data.error).toBeUndefined()
821
+ expect(data.result.isError).toBe(true)
822
+ expect(data.result.content[0].text).toContain('Database error')
823
+ })
824
+
825
+ it('should return a relation-filter access-denial error as a tool result naming the relation and related list', async () => {
826
+ mockPrisma.post.findMany.mockRejectedValue(
827
+ new RelationFilterAccessDeniedError('Post', 'author', 'User'),
828
+ )
829
+
830
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
831
+
832
+ const request = new Request('http://localhost/api/mcp', {
833
+ method: 'POST',
834
+ headers: { 'Content-Type': 'application/json' },
835
+ body: JSON.stringify({
836
+ jsonrpc: '2.0',
837
+ id: 1,
838
+ method: 'tools/call',
839
+ params: {
840
+ name: 'list_post_query',
841
+ arguments: { where: { author: { is: {} } } },
842
+ },
843
+ }),
844
+ })
845
+
846
+ const response = await handlers.POST(request)
847
+ expect(response.status).toBe(200)
848
+
849
+ const data = await response.json()
850
+ expect(data.error).toBeUndefined()
851
+ expect(data.result.isError).toBe(true)
852
+ expect(data.result.content[0].text).toContain('Post.author')
853
+ expect(data.result.content[0].text).toContain('User')
854
+ })
855
+
856
+ it('should return a depth-exceeded error as a tool result worded as a cost refusal', async () => {
857
+ mockPrisma.post.findMany.mockRejectedValue(
858
+ new AccessScopeDepthExceededError('Post', 'author', 3),
859
+ )
860
+
861
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
862
+
863
+ const request = new Request('http://localhost/api/mcp', {
864
+ method: 'POST',
865
+ headers: { 'Content-Type': 'application/json' },
866
+ body: JSON.stringify({
867
+ jsonrpc: '2.0',
868
+ id: 1,
869
+ method: 'tools/call',
870
+ params: {
871
+ name: 'list_post_query',
872
+ arguments: {},
873
+ },
874
+ }),
875
+ })
876
+
877
+ const response = await handlers.POST(request)
878
+ expect(response.status).toBe(200)
672
879
 
673
880
  const data = await response.json()
674
- expect(data.error.message).toContain('Database error')
881
+ expect(data.error).toBeUndefined()
882
+ expect(data.result.isError).toBe(true)
883
+ expect(data.result.content[0].text).toContain('Post.author')
884
+ expect(data.result.content[0].text).toContain('cost limit')
675
885
  })
676
886
  })
677
887
 
@@ -718,3 +928,484 @@ describe('MCP Handler', () => {
718
928
  })
719
929
  })
720
930
  })
931
+
932
+ // Coverage for issue #851 / ADR-0033: the `query` tool's `fields` projection
933
+ // argument, its generated per-session schema, and `tools/list` becoming
934
+ // per-session. Uses the same lightweight mocked-`context.db` harness as the
935
+ // suite above — these tests exercise the MCP-layer translation (schema
936
+ // shape, `fields` → `include` translation, response projection) rather than
937
+ // the real access-control engine, which is covered separately in
938
+ // `mcp-fields-projection-access.test.ts` against a real `getContext`.
939
+ describe('MCP Handler — fields projection (#851)', () => {
940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
941
+ let mockPrisma: any
942
+ let config: OpenSaasConfig
943
+ let getContext: (session?: { userId: string }) => AccessContext
944
+ let mockGetSession: McpSessionProvider
945
+
946
+ beforeEach(() => {
947
+ mockPrisma = {
948
+ user: { findMany: vi.fn() },
949
+ comment: { findMany: vi.fn() },
950
+ post: { findMany: vi.fn() },
951
+ secret: { findMany: vi.fn() },
952
+ draft: { findMany: vi.fn() },
953
+ category: { findMany: vi.fn() },
954
+ }
955
+
956
+ config = {
957
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
958
+ mcp: { enabled: true, basePath: '/api/mcp' },
959
+ lists: {
960
+ User: {
961
+ fields: {
962
+ name: { type: 'text' },
963
+ email: { type: 'text' },
964
+ },
965
+ access: { operation: { query: () => true } },
966
+ },
967
+ Comment: {
968
+ fields: {
969
+ text: { type: 'text' },
970
+ approved: { type: 'checkbox' },
971
+ // Field-level read denied — used to prove a nested `where`/
972
+ // `orderBy` can't name a field this session cannot read.
973
+ internalNote: { type: 'text', access: { read: () => false } },
974
+ post: { type: 'relationship', ref: 'Post.comments' },
975
+ },
976
+ access: { operation: { query: () => true } },
977
+ },
978
+ Post: {
979
+ fields: {
980
+ title: { type: 'text' },
981
+ content: { type: 'text' },
982
+ author: { type: 'relationship', ref: 'User' },
983
+ comments: { type: 'relationship', ref: 'Comment.post', many: true },
984
+ // Field-level read denied on the RELATIONSHIP FIELD ITSELF —
985
+ // distinct from a related list's operation-level access — used
986
+ // to prove `count` can't bypass it.
987
+ restrictedComments: {
988
+ type: 'relationship',
989
+ ref: 'Comment',
990
+ many: true,
991
+ access: { read: () => false },
992
+ },
993
+ secretInfo: { type: 'relationship', ref: 'Secret' },
994
+ draftRef: { type: 'relationship', ref: 'Draft' },
995
+ },
996
+ access: { operation: { query: () => true } },
997
+ },
998
+ // Denies query access outright — must vanish from `tools/list` as a
999
+ // tool owner AND from `Post`'s `fields` schema as a relation target.
1000
+ Secret: {
1001
+ fields: { value: { type: 'text' } },
1002
+ access: { operation: { query: () => false } },
1003
+ },
1004
+ // MCP-disabled — same double omission as `Secret`, different cause.
1005
+ Draft: {
1006
+ fields: { title: { type: 'text' } },
1007
+ access: { operation: { query: () => true } },
1008
+ mcp: { enabled: false },
1009
+ },
1010
+ // Self-referential — the schema must terminate at depth 2 rather
1011
+ // than recursing forever.
1012
+ Category: {
1013
+ fields: {
1014
+ name: { type: 'text' },
1015
+ parent: { type: 'relationship', ref: 'Category.children' },
1016
+ children: { type: 'relationship', ref: 'Category.parent', many: true },
1017
+ },
1018
+ access: { operation: { query: () => true } },
1019
+ },
1020
+ },
1021
+ }
1022
+
1023
+ getContext = vi.fn((session?: { userId: string }) => ({
1024
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1025
+ db: mockPrisma as any,
1026
+ session: session || null,
1027
+ prisma: mockPrisma,
1028
+ }))
1029
+
1030
+ mockGetSession = vi.fn(async () => ({ userId: 'user-123' }))
1031
+ })
1032
+
1033
+ async function listTools() {
1034
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
1035
+ const request = new Request('http://localhost/api/mcp', {
1036
+ method: 'POST',
1037
+ headers: { 'Content-Type': 'application/json' },
1038
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
1039
+ })
1040
+ const response = await handlers.POST(request)
1041
+ const data = await response.json()
1042
+ return data.result.tools as Array<{ name: string; inputSchema: Record<string, unknown> }>
1043
+ }
1044
+
1045
+ async function callQuery(
1046
+ name: string,
1047
+ args: Record<string, unknown>,
1048
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1049
+ ): Promise<any> {
1050
+ const handlers = createMcpHandlers({ config, getSession: mockGetSession, getContext })
1051
+ const request = new Request('http://localhost/api/mcp', {
1052
+ method: 'POST',
1053
+ headers: { 'Content-Type': 'application/json' },
1054
+ body: JSON.stringify({
1055
+ jsonrpc: '2.0',
1056
+ id: 1,
1057
+ method: 'tools/call',
1058
+ params: { name, arguments: args },
1059
+ }),
1060
+ })
1061
+ const response = await handlers.POST(request)
1062
+ return response.json()
1063
+ }
1064
+
1065
+ describe('generated `fields` schema on tools/list', () => {
1066
+ it('advertises scalars as booleans and relations as nested selectors', async () => {
1067
+ const tools = await listTools()
1068
+ const postQuery = tools.find((t) => t.name === 'list_post_query')!
1069
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1070
+ const fieldsSchema = (postQuery.inputSchema.properties as any).fields
1071
+
1072
+ expect(fieldsSchema.properties.title).toMatchObject({ type: 'boolean' })
1073
+ expect(fieldsSchema.properties.content).toMatchObject({ type: 'boolean' })
1074
+
1075
+ // To-one: nested `fields` only, required, no where/orderBy/take/skip/count.
1076
+ expect(fieldsSchema.properties.author.required).toEqual(['fields'])
1077
+ const authorFields = fieldsSchema.properties.author.properties.fields.properties
1078
+ expect(authorFields.name).toMatchObject({ type: 'boolean' })
1079
+ expect(authorFields.email).toMatchObject({ type: 'boolean' })
1080
+ expect(fieldsSchema.properties.author.properties.where).toBeUndefined()
1081
+
1082
+ // To-many: fields optional, plus where/orderBy/take/skip/count.
1083
+ expect(fieldsSchema.properties.comments.required).toBeUndefined()
1084
+ const commentFields = fieldsSchema.properties.comments.properties.fields.properties
1085
+ expect(commentFields.text).toMatchObject({ type: 'boolean' })
1086
+ expect(commentFields.approved).toMatchObject({ type: 'boolean' })
1087
+ expect(fieldsSchema.properties.comments.properties.where).toBeDefined()
1088
+ expect(fieldsSchema.properties.comments.properties.orderBy).toBeDefined()
1089
+ expect(fieldsSchema.properties.comments.properties.take).toBeDefined()
1090
+ expect(fieldsSchema.properties.comments.properties.skip).toBeDefined()
1091
+ expect(fieldsSchema.properties.comments.properties.count).toEqual({
1092
+ type: 'boolean',
1093
+ description: expect.any(String),
1094
+ })
1095
+ })
1096
+
1097
+ it('omits a relation whose target list denies query access', async () => {
1098
+ const tools = await listTools()
1099
+ const postQuery = tools.find((t) => t.name === 'list_post_query')!
1100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1101
+ const fieldsSchema = (postQuery.inputSchema.properties as any).fields
1102
+ expect(fieldsSchema.properties.secretInfo).toBeUndefined()
1103
+ })
1104
+
1105
+ it('omits a relation whose target list has mcp.enabled: false', async () => {
1106
+ const tools = await listTools()
1107
+ const postQuery = tools.find((t) => t.name === 'list_post_query')!
1108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1109
+ const fieldsSchema = (postQuery.inputSchema.properties as any).fields
1110
+ expect(fieldsSchema.properties.draftRef).toBeUndefined()
1111
+ })
1112
+
1113
+ it('terminates a self-referential relationship at depth 2 (no relation fields at level 2)', async () => {
1114
+ const tools = await listTools()
1115
+ const categoryQuery = tools.find((t) => t.name === 'list_category_query')!
1116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1117
+ const fieldsSchema = (categoryQuery.inputSchema.properties as any).fields
1118
+
1119
+ // Only scalars (plus the always-present system fields) — no `parent`/
1120
+ // `children` at this depth, which is what actually terminates the
1121
+ // self-reference.
1122
+ const parentFields = fieldsSchema.properties.parent.properties.fields.properties
1123
+ expect(Object.keys(parentFields).sort()).toEqual(['createdAt', 'id', 'name', 'updatedAt'])
1124
+ expect(parentFields.name).toMatchObject({ type: 'boolean' })
1125
+
1126
+ const childrenFields = fieldsSchema.properties.children.properties.fields.properties
1127
+ expect(Object.keys(childrenFields).sort()).toEqual(['createdAt', 'id', 'name', 'updatedAt'])
1128
+ expect(childrenFields.name).toMatchObject({ type: 'boolean' })
1129
+ })
1130
+ })
1131
+
1132
+ describe('tools/list is per-session', () => {
1133
+ it('omits all four CRUD tools for a list whose query access denies the session', async () => {
1134
+ const tools = await listTools()
1135
+ const toolNames = tools.map((t) => t.name)
1136
+ expect(toolNames).not.toContain('list_secret_query')
1137
+ expect(toolNames).not.toContain('list_secret_create')
1138
+ expect(toolNames).not.toContain('list_secret_update')
1139
+ expect(toolNames).not.toContain('list_secret_delete')
1140
+ })
1141
+
1142
+ it('omits all four CRUD tools for a list with mcp.enabled: false', async () => {
1143
+ const tools = await listTools()
1144
+ const toolNames = tools.map((t) => t.name)
1145
+ expect(toolNames).not.toContain('list_draft_query')
1146
+ })
1147
+ })
1148
+
1149
+ describe('query dispatch with `fields`', () => {
1150
+ it('selects only scalar fields, issuing no `include`', async () => {
1151
+ mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello', content: 'World' }])
1152
+
1153
+ const data = await callQuery('list_post_query', { fields: { title: true } })
1154
+
1155
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1156
+ expect.not.objectContaining({ include: expect.anything() }),
1157
+ )
1158
+ const result = JSON.parse(data.result.content[0].text)
1159
+ expect(result.items).toEqual([{ id: '1', title: 'Hello' }])
1160
+ })
1161
+
1162
+ it('selects a to-one relation with nested fields', async () => {
1163
+ mockPrisma.post.findMany.mockResolvedValue([
1164
+ { id: '1', title: 'Hello', author: { id: 'u1', name: 'Ada', email: 'ada@example.com' } },
1165
+ ])
1166
+
1167
+ const data = await callQuery('list_post_query', {
1168
+ fields: { title: true, author: { fields: { name: true } } },
1169
+ })
1170
+
1171
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1172
+ expect.objectContaining({ include: { author: true } }),
1173
+ )
1174
+ const result = JSON.parse(data.result.content[0].text)
1175
+ expect(result.items).toEqual([{ id: '1', title: 'Hello', author: { id: 'u1', name: 'Ada' } }])
1176
+ })
1177
+
1178
+ it('honours nested where/orderBy/take/skip on a to-many relation, applying the default take ceiling when omitted', async () => {
1179
+ mockPrisma.post.findMany.mockResolvedValue([{ id: '1', comments: [] }])
1180
+
1181
+ await callQuery('list_post_query', {
1182
+ fields: {
1183
+ comments: {
1184
+ fields: { text: true },
1185
+ where: { approved: { equals: true } },
1186
+ orderBy: { text: 'asc' },
1187
+ skip: 2,
1188
+ },
1189
+ },
1190
+ })
1191
+
1192
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1193
+ expect.objectContaining({
1194
+ include: {
1195
+ comments: {
1196
+ where: { approved: { equals: true } },
1197
+ orderBy: { text: 'asc' },
1198
+ take: 5,
1199
+ skip: 2,
1200
+ },
1201
+ },
1202
+ }),
1203
+ )
1204
+ })
1205
+
1206
+ it('clamps a nested take above the hard cap', async () => {
1207
+ mockPrisma.post.findMany.mockResolvedValue([{ id: '1', comments: [] }])
1208
+
1209
+ await callQuery('list_post_query', {
1210
+ fields: { comments: { fields: { text: true }, take: 9999 } },
1211
+ })
1212
+
1213
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1214
+ expect.objectContaining({
1215
+ include: { comments: { take: 50 } },
1216
+ }),
1217
+ )
1218
+ })
1219
+
1220
+ it('requests a to-many count in place of its rows', async () => {
1221
+ // The relation is still named in the include, fetched with the SAME
1222
+ // real rows a `fields`-and-`count` request would get (never a
1223
+ // synthetic `take: 0`, which would make field-visibility's read-access
1224
+ // check see a permanently-empty relation) — see projectMcpResult's doc
1225
+ // comment. The rows themselves are simply never added to
1226
+ // `fieldSelection`, so they never reach the projected output.
1227
+ mockPrisma.post.findMany.mockResolvedValue([
1228
+ { id: '1', comments: [{ text: 'hi' }], _count: { comments: 7 } },
1229
+ ])
1230
+
1231
+ const data = await callQuery('list_post_query', {
1232
+ fields: { comments: { count: true } },
1233
+ })
1234
+
1235
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1236
+ expect.objectContaining({
1237
+ include: { comments: { take: 5 }, _count: { select: { comments: true } } },
1238
+ }),
1239
+ )
1240
+ const result = JSON.parse(data.result.content[0].text)
1241
+ expect(result.items).toEqual([{ id: '1', comments: 7 }])
1242
+ })
1243
+
1244
+ it('requests a to-many count alongside its rows', async () => {
1245
+ mockPrisma.post.findMany.mockResolvedValue([
1246
+ { id: '1', comments: [{ text: 'hi' }], _count: { comments: 7 } },
1247
+ ])
1248
+
1249
+ const data = await callQuery('list_post_query', {
1250
+ fields: { comments: { fields: { text: true }, count: true } },
1251
+ })
1252
+
1253
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1254
+ expect.objectContaining({
1255
+ include: {
1256
+ comments: { take: 5 },
1257
+ _count: { select: { comments: true } },
1258
+ },
1259
+ }),
1260
+ )
1261
+ const result = JSON.parse(data.result.content[0].text)
1262
+ expect(result.items).toEqual([{ id: '1', comments: { items: [{ text: 'hi' }], count: 7 } }])
1263
+ })
1264
+
1265
+ it('refuses an unadvertised field name as an isError tool result', async () => {
1266
+ const data = await callQuery('list_post_query', { fields: { madeUpField: true } })
1267
+
1268
+ expect(data.error).toBeUndefined()
1269
+ expect(data.result.isError).toBe(true)
1270
+ expect(data.result.content[0].text).toContain('madeUpField')
1271
+ })
1272
+
1273
+ it('refuses a relation named two levels deep as an isError tool result', async () => {
1274
+ const data = await callQuery('list_post_query', {
1275
+ fields: { comments: { fields: { post: true } } },
1276
+ })
1277
+
1278
+ expect(data.error).toBeUndefined()
1279
+ expect(data.result.isError).toBe(true)
1280
+ expect(data.result.content[0].text).toContain('post')
1281
+ })
1282
+
1283
+ it('refuses a relation field selected with `true` instead of a selector object', async () => {
1284
+ const data = await callQuery('list_post_query', { fields: { author: true } })
1285
+
1286
+ expect(data.result.isError).toBe(true)
1287
+ })
1288
+
1289
+ it('refuses a scalar field selected with a non-true value', async () => {
1290
+ const data = await callQuery('list_post_query', { fields: { title: 'yes' } })
1291
+
1292
+ expect(data.result.isError).toBe(true)
1293
+ })
1294
+
1295
+ it('refuses a relation whose target list denies query access', async () => {
1296
+ const data = await callQuery('list_post_query', {
1297
+ fields: { secretInfo: { fields: { value: true } } },
1298
+ })
1299
+
1300
+ expect(data.result.isError).toBe(true)
1301
+ })
1302
+
1303
+ it('always returns id, even when the projection never names it', async () => {
1304
+ mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello' }])
1305
+
1306
+ const data = await callQuery('list_post_query', { fields: { title: true } })
1307
+
1308
+ const result = JSON.parse(data.result.content[0].text)
1309
+ expect(result.items).toEqual([{ id: '1', title: 'Hello' }])
1310
+ })
1311
+
1312
+ it('accepts id/createdAt/updatedAt as explicitly selectable system fields', async () => {
1313
+ mockPrisma.post.findMany.mockResolvedValue([
1314
+ { id: '1', title: 'Hello', createdAt: 't1', updatedAt: 't2' },
1315
+ ])
1316
+
1317
+ const data = await callQuery('list_post_query', {
1318
+ fields: { id: true, createdAt: true, updatedAt: true },
1319
+ })
1320
+
1321
+ const result = JSON.parse(data.result.content[0].text)
1322
+ expect(result.items).toEqual([{ id: '1', createdAt: 't1', updatedAt: 't2' }])
1323
+ })
1324
+
1325
+ it('refuses a malformed nested where/take/orderBy/skip/count with a clear message rather than an opaque Prisma error', async () => {
1326
+ const badWhere = await callQuery('list_post_query', {
1327
+ fields: { comments: { fields: { text: true }, where: 'not-an-object' } },
1328
+ })
1329
+ expect(badWhere.result.isError).toBe(true)
1330
+ expect(badWhere.result.content[0].text).toContain('where')
1331
+
1332
+ const badTake = await callQuery('list_post_query', {
1333
+ fields: { comments: { fields: { text: true }, take: 'lots' } },
1334
+ })
1335
+ expect(badTake.result.isError).toBe(true)
1336
+ expect(badTake.result.content[0].text).toContain('take')
1337
+
1338
+ const badCount = await callQuery('list_post_query', {
1339
+ fields: { comments: { count: 'yes' } },
1340
+ })
1341
+ expect(badCount.result.isError).toBe(true)
1342
+ expect(badCount.result.content[0].text).toContain('count')
1343
+ })
1344
+
1345
+ it('refuses a negative nested take rather than allowing reverse-pagination to bypass the row cap', async () => {
1346
+ const data = await callQuery('list_post_query', {
1347
+ fields: { comments: { fields: { text: true }, take: -100000 } },
1348
+ })
1349
+
1350
+ expect(data.result.isError).toBe(true)
1351
+ expect(data.result.content[0].text).toContain('take')
1352
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1353
+ })
1354
+
1355
+ it('refuses a nested where naming a field-level-denied field on the related list', async () => {
1356
+ const data = await callQuery('list_post_query', {
1357
+ fields: {
1358
+ comments: { fields: { text: true }, where: { internalNote: { equals: 'x' } } },
1359
+ },
1360
+ })
1361
+
1362
+ expect(data.result.isError).toBe(true)
1363
+ expect(data.result.content[0].text).toContain('internalNote')
1364
+ expect(mockPrisma.post.findMany).not.toHaveBeenCalled()
1365
+ })
1366
+
1367
+ it('refuses a nested orderBy naming an undeclared field on the related list', async () => {
1368
+ const data = await callQuery('list_post_query', {
1369
+ fields: {
1370
+ comments: { fields: { text: true }, orderBy: { bogusField: 'asc' } },
1371
+ },
1372
+ })
1373
+
1374
+ expect(data.result.isError).toBe(true)
1375
+ expect(data.result.content[0].text).toContain('bogusField')
1376
+ })
1377
+
1378
+ it('names a count-only relation in the include with real rows (never a synthetic take: 0) so field-visibility can evaluate its own read access against the true value', async () => {
1379
+ mockPrisma.post.findMany.mockResolvedValue([
1380
+ { id: '1', restrictedComments: [{ text: 'hi' }], _count: { restrictedComments: 3 } },
1381
+ ])
1382
+
1383
+ await callQuery('list_post_query', { fields: { restrictedComments: { count: true } } })
1384
+
1385
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
1386
+ expect.objectContaining({
1387
+ include: expect.objectContaining({ restrictedComments: { take: 5 } }),
1388
+ }),
1389
+ )
1390
+ })
1391
+
1392
+ // This mocked harness bypasses the real `filterReadableFields`, so it
1393
+ // can only simulate the outcome (a denied relation key absent from the
1394
+ // row `context.db` returns) rather than exercise the real per-row
1395
+ // access check — see `mcp-fields-projection-access.test.ts`'s
1396
+ // "suppresses a relation count denied by the relationship field's own
1397
+ // access" for the end-to-end version through the real pipeline.
1398
+ it('suppresses a count whose relation key is absent from the row (what a field-visibility denial produces)', async () => {
1399
+ mockPrisma.post.findMany.mockResolvedValue([
1400
+ { id: '1', _count: { restrictedComments: 3 } }, // no `restrictedComments` key
1401
+ ])
1402
+
1403
+ const data = await callQuery('list_post_query', {
1404
+ fields: { restrictedComments: { count: true } },
1405
+ })
1406
+
1407
+ const result = JSON.parse(data.result.content[0].text)
1408
+ expect(result.items).toEqual([{ id: '1' }])
1409
+ })
1410
+ })
1411
+ })