@nextsparkjs/core 0.1.0-beta.184 → 0.1.0-beta.185

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 (60) hide show
  1. package/dist/hooks/useAuth.d.ts +74 -25
  2. package/dist/hooks/useAuth.d.ts.map +1 -1
  3. package/dist/hooks/useValidationSchemas.d.ts +7 -7
  4. package/dist/lib/api/auth/dual-auth.d.ts +7 -0
  5. package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
  6. package/dist/lib/api/auth/dual-auth.js +1 -1
  7. package/dist/lib/api/auth.d.ts +20 -3
  8. package/dist/lib/api/auth.d.ts.map +1 -1
  9. package/dist/lib/api/auth.js +37 -19
  10. package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
  11. package/dist/lib/api/entity/generic-handler.js +19 -22
  12. package/dist/lib/auth.d.ts +1 -3057
  13. package/dist/lib/auth.d.ts.map +1 -1
  14. package/dist/lib/billing/schema.d.ts +1 -1
  15. package/dist/lib/mcp/audit.d.ts +17 -0
  16. package/dist/lib/mcp/audit.d.ts.map +1 -0
  17. package/dist/lib/mcp/audit.js +48 -0
  18. package/dist/lib/mcp/engine.d.ts +28 -0
  19. package/dist/lib/mcp/engine.d.ts.map +1 -0
  20. package/dist/lib/mcp/engine.js +158 -0
  21. package/dist/lib/mcp/errors.d.ts +16 -0
  22. package/dist/lib/mcp/errors.d.ts.map +1 -0
  23. package/dist/lib/mcp/errors.js +66 -0
  24. package/dist/lib/mcp/executor.d.ts +15 -0
  25. package/dist/lib/mcp/executor.d.ts.map +1 -0
  26. package/dist/lib/mcp/executor.js +63 -0
  27. package/dist/lib/mcp/index.d.ts +9 -0
  28. package/dist/lib/mcp/index.d.ts.map +1 -0
  29. package/dist/lib/mcp/index.js +32 -0
  30. package/dist/lib/mcp/schema-builder.d.ts +45 -0
  31. package/dist/lib/mcp/schema-builder.d.ts.map +1 -0
  32. package/dist/lib/mcp/schema-builder.js +212 -0
  33. package/dist/lib/mcp/tool-generator.d.ts +31 -0
  34. package/dist/lib/mcp/tool-generator.d.ts.map +1 -0
  35. package/dist/lib/mcp/tool-generator.js +271 -0
  36. package/dist/lib/mcp/transport.d.ts +31 -0
  37. package/dist/lib/mcp/transport.d.ts.map +1 -0
  38. package/dist/lib/mcp/transport.js +58 -0
  39. package/dist/lib/mcp/types.d.ts +184 -0
  40. package/dist/lib/mcp/types.d.ts.map +1 -0
  41. package/dist/lib/mcp/types.js +11 -0
  42. package/dist/styles/classes.json +1 -1
  43. package/dist/styles/ui.css +1 -1
  44. package/dist/templates/app/api/mcp/route.ts +173 -0
  45. package/dist/templates/app/api/v1/api-keys/route.ts +15 -3
  46. package/dist/templates/app/layout.tsx +3 -0
  47. package/package.json +8 -3
  48. package/scripts/build/registry/__tests__/mcp-registry.test.mjs +64 -0
  49. package/scripts/build/registry/__tests__/scope-registry.test.mjs +88 -0
  50. package/scripts/build/registry/discovery/mcp-overrides.mjs +72 -0
  51. package/scripts/build/registry/discovery/mcp-overrides.test.mjs +86 -0
  52. package/scripts/build/registry/generators/mcp-registry.mjs +61 -0
  53. package/scripts/build/registry/generators/scope-registry.mjs +133 -88
  54. package/scripts/build/registry.mjs +8 -4
  55. package/templates/app/api/mcp/route.ts +173 -0
  56. package/templates/app/api/v1/api-keys/route.ts +15 -3
  57. package/templates/app/layout.tsx +3 -0
  58. package/tests/jest/__mocks__/@nextsparkjs/registries/scope-registry.ts +56 -6
  59. package/dist/lib/auth-client.d.ts +0 -3595
  60. package/dist/lib/auth-client.d.ts.map +0 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * MCP endpoint — POST /api/mcp (Streamable HTTP, stateless JSON mode).
3
+ *
4
+ * Auth: API key ONLY (Authorization: Bearer sk_... or x-api-key). Session
5
+ * cookies are rejected — this endpoint is for MCP clients, not browsers.
6
+ * authenticateRequest runs ONCE here; the resolved identity, team context and
7
+ * api_key id travel to every tool via ToolExecutionContext.
8
+ *
9
+ * Tools and overrides are entirely registry-driven: every access.api-enabled
10
+ * entity in ENTITY_REGISTRY becomes a set of tools automatically, and any
11
+ * `entities/<slug>/mcp.ts` a theme defines is picked up via the generated
12
+ * MCP_OVERRIDES registry — no per-app hand-wiring required.
13
+ *
14
+ * Security posture:
15
+ * - kill-switch: MCP_ENABLED (dev: on unless 'false'; production: OFF unless 'true')
16
+ * - Origin validated when present (DNS-rebinding protection); no CORS reflection
17
+ * - rate limit: tier 'api' — with the in-process executor this is the ONLY
18
+ * rate limit on the whole MCP surface (deploy prerequisite: Redis)
19
+ * - team-role permissions + scopes + ownership/field guards come from the
20
+ * real generic entity handlers the executor calls in-process (#94/#95)
21
+ * - every tool call is audited to api_audit_log
22
+ */
23
+
24
+ import { NextRequest, NextResponse } from 'next/server'
25
+ import {
26
+ authenticateRequest,
27
+ resolveTeamContext,
28
+ } from '@nextsparkjs/core/lib/api/auth/dual-auth'
29
+ import { validateApiKey } from '@nextsparkjs/core/lib/api/auth'
30
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
31
+ import { setEntityRegistry } from '@nextsparkjs/core/lib/entities/queries'
32
+ import { createMcpEngine } from '@nextsparkjs/core/lib/mcp'
33
+ import type { ToolExecutionContext } from '@nextsparkjs/core/lib/mcp'
34
+ import type { EntityConfig } from '@nextsparkjs/core/lib/entities/types'
35
+ // Import registries directly - webpack resolves @nextsparkjs/registries alias at compile time
36
+ import { ENTITY_REGISTRY, ENTITY_METADATA } from '@nextsparkjs/registries/entity-registry'
37
+ import { MCP_OVERRIDES } from '@nextsparkjs/registries/mcp-registry'
38
+ import { API_PRESETS_REGISTRY } from '@nextsparkjs/registries/api-presets-registry'
39
+
40
+ // Initialize registry at module load time (before any handler runs)
41
+ setEntityRegistry(ENTITY_REGISTRY, ENTITY_METADATA)
42
+
43
+ // Registry entries are wrappers ({ name, config, tableName, ... }) — the
44
+ // engine consumes the EntityConfig map.
45
+ const entityConfigs = Object.fromEntries(
46
+ Object.entries(ENTITY_REGISTRY as Record<string, { config: EntityConfig }>).map(
47
+ ([slug, entry]) => [slug, entry.config]
48
+ )
49
+ )
50
+
51
+ // Tool descriptions are seeded from api/presets.ts (summary → entity
52
+ // description, presets → few-shot examples) — the same registry the API
53
+ // Explorer already uses, keyed by endpoint instead of slug here.
54
+ const presetsBySlug = Object.fromEntries(
55
+ Object.entries(API_PRESETS_REGISTRY.endpoints ?? {}).map(([endpoint, data]) => [
56
+ endpoint.replace(/^\/api\/v1\//, ''),
57
+ { summary: data.summary, presets: data.presets },
58
+ ])
59
+ )
60
+
61
+ const toolPrefix = (process.env.MCP_TOOL_PREFIX || process.env.NEXT_PUBLIC_ACTIVE_THEME || 'app')
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '_')
64
+ .replace(/^_+|_+$/g, '')
65
+
66
+ const engine = createMcpEngine(entityConfigs, {
67
+ toolPrefix,
68
+ overrides: MCP_OVERRIDES,
69
+ presets: presetsBySlug,
70
+ })
71
+
72
+ const API_KEY_HELP =
73
+ 'This endpoint requires an API key: generate one in Settings → API Keys and configure it in your MCP client as the header "Authorization: Bearer sk_...".'
74
+
75
+ function isMcpEnabled(): boolean {
76
+ const flag = process.env.MCP_ENABLED
77
+ if (process.env.NODE_ENV === 'production') return flag === 'true'
78
+ return flag !== 'false'
79
+ }
80
+
81
+ function isOriginAllowed(request: NextRequest): boolean {
82
+ const origin = request.headers.get('origin')
83
+ if (!origin) return true // non-browser MCP clients don't send Origin
84
+ const allowed = new Set(
85
+ [request.nextUrl.origin, process.env.NEXT_PUBLIC_APP_URL, process.env.BETTER_AUTH_URL].filter(
86
+ Boolean
87
+ )
88
+ )
89
+ return allowed.has(origin)
90
+ }
91
+
92
+ function jsonRpcError(
93
+ status: number,
94
+ code: number,
95
+ message: string,
96
+ id: string | number | null = null
97
+ ): NextResponse {
98
+ return NextResponse.json({ jsonrpc: '2.0', error: { code, message }, id }, { status })
99
+ }
100
+
101
+ async function handleMcpPost(request: NextRequest): Promise<NextResponse> {
102
+ if (!isMcpEnabled()) {
103
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
104
+ }
105
+ if (!isOriginAllowed(request)) {
106
+ return jsonRpcError(403, -32000, 'Origin not allowed')
107
+ }
108
+
109
+ const authResult = await authenticateRequest(request)
110
+ if (!authResult.success || authResult.type !== 'api-key' || !authResult.user) {
111
+ return jsonRpcError(401, -32001, API_KEY_HELP)
112
+ }
113
+
114
+ // Same cached lookup the auth already did — needed for the audit trail.
115
+ const apiKeyAuth = await validateApiKey(request)
116
+
117
+ const teamResult = await resolveTeamContext(request, authResult)
118
+ if (teamResult instanceof Response) {
119
+ const status = teamResult.status
120
+ return jsonRpcError(
121
+ status,
122
+ -32002,
123
+ status === 403
124
+ ? 'The API key user is not a member of the team specified in x-team-id.'
125
+ : 'Could not resolve the user team. Send the x-team-id header or assign a default team.'
126
+ )
127
+ }
128
+
129
+ let body: unknown
130
+ try {
131
+ body = await request.json()
132
+ } catch {
133
+ return jsonRpcError(400, -32700, 'Parse error: body must be JSON-RPC')
134
+ }
135
+
136
+ const ctx: ToolExecutionContext = {
137
+ userId: authResult.user.id,
138
+ userRole: authResult.user.role,
139
+ teamId: teamResult,
140
+ authHeader:
141
+ request.headers.get('authorization') ?? `Bearer ${request.headers.get('x-api-key') ?? ''}`,
142
+ apiKeyId: apiKeyAuth?.keyId ?? null,
143
+ scopes: authResult.scopes ?? [],
144
+ }
145
+
146
+ try {
147
+ const result = await engine.handleJsonRpc(body, ctx)
148
+ if (result === null) {
149
+ // Notification(s) only — accepted, nothing to return.
150
+ return new NextResponse(null, { status: 202 })
151
+ }
152
+ return NextResponse.json(result, { status: 200 })
153
+ } catch (error) {
154
+ console.error('[mcp] request failed:', error)
155
+ return jsonRpcError(500, -32603, 'Internal error processing MCP request')
156
+ }
157
+ }
158
+
159
+ export const POST = withRateLimitTier(handleMcpPost, 'api')
160
+
161
+ export async function GET(): Promise<NextResponse> {
162
+ return NextResponse.json(
163
+ { error: 'Method not allowed. MCP requests must be POST (stateless JSON mode, no SSE).' },
164
+ { status: 405, headers: { Allow: 'POST' } }
165
+ )
166
+ }
167
+
168
+ export async function DELETE(): Promise<NextResponse> {
169
+ return NextResponse.json(
170
+ { error: 'Method not allowed. This server is stateless: there is no session to delete.' },
171
+ { status: 405, headers: { Allow: 'POST' } }
172
+ )
173
+ }
@@ -7,7 +7,7 @@ import {
7
7
  handleCorsPreflightRequest,
8
8
  addCorsHeaders
9
9
  } from '@nextsparkjs/core/lib/api/helpers';
10
- import { authenticateRequest, hasRequiredScope } from '@nextsparkjs/core/lib/api/auth/dual-auth';
10
+ import { authenticateRequest, hasRequiredScope, resolveTeamContext } from '@nextsparkjs/core/lib/api/auth/dual-auth';
11
11
  import { ApiKeyManager, API_SCOPES, API_KEY_LIMITS } from '@nextsparkjs/core/lib/api/keys';
12
12
  import { validateScopesForUser } from '@nextsparkjs/core/lib/api/auth';
13
13
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit';
@@ -177,8 +177,20 @@ export const POST = withRateLimitTier(withApiLogging(async (req: NextRequest): P
177
177
  return addCorsHeaders(response);
178
178
  }
179
179
 
180
- // Validar que el usuario pueda crear API keys con estos scopes
181
- const userScopeValidation = await validateScopesForUser(authResult.user!.id, validatedData.scopes);
180
+ // Validar que el usuario pueda crear API keys con estos scopes.
181
+ // Scope minting is TEAM-role based (see validateScopesForUser) — resolve
182
+ // the team this key is being minted for, unless the caller is a global
183
+ // superadmin, which validateScopesForUser bypasses regardless of team.
184
+ let teamId: string | null = null;
185
+ if (authResult.user!.role !== 'superadmin') {
186
+ const teamResult = await resolveTeamContext(req, authResult);
187
+ if (teamResult instanceof NextResponse) {
188
+ return teamResult;
189
+ }
190
+ teamId = teamResult;
191
+ }
192
+
193
+ const userScopeValidation = await validateScopesForUser(authResult.user!.id, teamId, validatedData.scopes);
182
194
  if (!userScopeValidation.valid) {
183
195
  const response = createApiError(
184
196
  'You do not have permission to create API keys with these scopes',
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Metadata } from "next"
8
+ import { Suspense } from "react"
8
9
  import { Geist, Geist_Mono } from "next/font/google"
9
10
  import { NextIntlClientProvider } from 'next-intl'
10
11
  import { getMessages } from 'next-intl/server'
@@ -13,6 +14,7 @@ import "./globals.css"
13
14
  import { getBillingResourceHints } from "@nextsparkjs/core/lib/billing/gateways/factory"
14
15
  import { ThemeProvider as NextThemeProvider } from "@nextsparkjs/core/providers/theme-provider"
15
16
  import { ThemeProvider as CustomThemeProvider } from "@nextsparkjs/core/lib/theme/ThemeProvider"
17
+ import { Toaster } from "@nextsparkjs/core/components/ui/sonner"
16
18
  import { getUserLocale } from '@nextsparkjs/core/lib/locale'
17
19
  import { TranslationContextManager } from "@nextsparkjs/core/providers/TranslationContextManager"
18
20
  import { PluginService } from '@nextsparkjs/core/lib/services'
@@ -85,6 +87,7 @@ export default async function RootLayout({
85
87
  <CustomThemeProvider>
86
88
  <TranslationContextManager />
87
89
  <main>{children}</main>
90
+ <Suspense><Toaster position="bottom-left" /></Suspense>
88
91
  </CustomThemeProvider>
89
92
  </NextThemeProvider>
90
93
  </NextIntlClientProvider>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/core",
3
- "version": "0.1.0-beta.184",
3
+ "version": "0.1.0-beta.185",
4
4
  "description": "NextSpark - The complete SaaS framework for Next.js",
5
5
  "license": "MIT",
6
6
  "author": "NextSpark <hello@nextspark.dev>",
@@ -64,6 +64,10 @@
64
64
  "types": "./dist/lib/services/index.d.ts",
65
65
  "import": "./dist/lib/services/index.js"
66
66
  },
67
+ "./lib/mcp": {
68
+ "types": "./dist/lib/mcp/index.d.ts",
69
+ "import": "./dist/lib/mcp/index.js"
70
+ },
67
71
  "./lib/config": {
68
72
  "types": "./dist/lib/config/index.d.ts",
69
73
  "import": "./dist/lib/config/index.js"
@@ -403,6 +407,7 @@
403
407
  "@dnd-kit/utilities": "^3.2.2",
404
408
  "@hookform/resolvers": "^5.2.1",
405
409
  "@inquirer/prompts": "^7.2.0",
410
+ "@modelcontextprotocol/sdk": "^1.30.0",
406
411
  "@nextsparkjs/ui": "^0.1.0-beta.2",
407
412
  "@polar-sh/sdk": "^0.43.1",
408
413
  "@radix-ui/react-accordion": "^1.2.12",
@@ -437,7 +442,7 @@
437
442
  "@upstash/ratelimit": "^2.0.3",
438
443
  "@upstash/redis": "^1.20.6",
439
444
  "@vercel/blob": "^2.0.0",
440
- "better-auth": "^1.3.5",
445
+ "better-auth": "~1.6.30",
441
446
  "chalk": "^5.4.1",
442
447
  "jiti": "^2.7.0",
443
448
  "kysely": "0.28.17",
@@ -470,7 +475,7 @@
470
475
  "tailwind-merge": "^3.3.1",
471
476
  "uuid": "^13.0.0",
472
477
  "zod": "^4.1.5",
473
- "@nextsparkjs/testing": "0.1.0-beta.184"
478
+ "@nextsparkjs/testing": "0.1.0-beta.185"
474
479
  },
475
480
  "scripts": {
476
481
  "postinstall": "node scripts/postinstall.mjs || true",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tests for the mcp-registry generator.
3
+ *
4
+ * Run: node --test packages/core/scripts/build/registry/__tests__/mcp-registry.test.mjs
5
+ */
6
+
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { generateMcpRegistry } from '../generators/mcp-registry.mjs'
11
+
12
+ const CONFIG = { outputDir: '/project/.nextspark/registries', isNpmMode: false }
13
+
14
+ test('emits an empty MCP_OVERRIDES map when no overrides were discovered', () => {
15
+ const out = generateMcpRegistry([], CONFIG)
16
+ assert.match(out, /export const MCP_OVERRIDES: Record<string, McpEntityOverride> = \{\s*\}/)
17
+ assert.doesNotMatch(out, /^import .+McpOverride/m)
18
+ })
19
+
20
+ test('emits one literal import + map entry per discovered override', () => {
21
+ const out = generateMcpRegistry(
22
+ [{ slug: 'customers', importPath: '@/contents/themes/default/entities/customers/mcp', themeName: 'default' }],
23
+ CONFIG
24
+ )
25
+ assert.match(out, /import CustomersMcpOverride from '@\/contents\/themes\/default\/entities\/customers\/mcp'/)
26
+ assert.match(out, /'customers': CustomersMcpOverride,/)
27
+ })
28
+
29
+ test('imports the McpEntityOverride type from @/core/lib/mcp in monorepo mode', () => {
30
+ const out = generateMcpRegistry([], CONFIG)
31
+ assert.match(out, /import type \{ McpEntityOverride \} from '@\/core\/lib\/mcp'/)
32
+ })
33
+
34
+ test('converts @/core/lib/mcp to @nextsparkjs/core/lib/mcp in npm mode', () => {
35
+ const out = generateMcpRegistry([], { ...CONFIG, isNpmMode: true })
36
+ assert.match(out, /import type \{ McpEntityOverride \} from '@nextsparkjs\/core\/lib\/mcp'/)
37
+ })
38
+
39
+ test('PascalCases hyphenated/underscored/nested slugs into a valid identifier', () => {
40
+ const out = generateMcpRegistry(
41
+ [
42
+ { slug: 'meal-plans', importPath: '@/contents/themes/default/entities/meal-plans/mcp' },
43
+ { slug: 'pantry_items', importPath: '@/contents/themes/default/entities/pantry_items/mcp' },
44
+ ],
45
+ CONFIG
46
+ )
47
+ assert.match(out, /import MealPlansMcpOverride from/)
48
+ assert.match(out, /import PantryItemsMcpOverride from/)
49
+ assert.match(out, /'meal-plans': MealPlansMcpOverride,/)
50
+ assert.match(out, /'pantry_items': PantryItemsMcpOverride,/)
51
+ })
52
+
53
+ test('handles multiple overrides, one import + entry each, in discovery order', () => {
54
+ const out = generateMcpRegistry(
55
+ [
56
+ { slug: 'customers', importPath: '@/contents/themes/default/entities/customers/mcp' },
57
+ { slug: 'tasks', importPath: '@/contents/themes/default/entities/tasks/mcp' },
58
+ ],
59
+ CONFIG
60
+ )
61
+ const importLines = out.match(/^import \w+McpOverride from .+$/gm) ?? []
62
+ assert.equal(importLines.length, 2)
63
+ assert.match(out, /'customers': CustomersMcpOverride,[\s\S]*'tasks': TasksMcpOverride,/)
64
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tests for the scope-registry generator.
3
+ *
4
+ * The role→scope computation no longer happens at generation time (see the
5
+ * rationale comment at the top of `generators/scope-registry.mjs`) — it's
6
+ * emitted as TypeScript that computes `SCOPE_CONFIG.roles` at IMPORT time,
7
+ * from the sibling generated `entity-registry.ts`/`permissions-registry.ts`.
8
+ * So this generator-level test can only assert on the EMITTED source: the
9
+ * correct static imports, the defensive entity-registry guard, the
10
+ * role/permission-derived scope logic, and the untouched flag/restriction
11
+ * literals. The actual `SCOPE_CONFIG.roles` VALUES for a real role are
12
+ * covered by `packages/core/tests/jest/services/scope.service.test.ts`
13
+ * (against a realistic mock) and by manual inspection of the real generated
14
+ * output after `node packages/core/scripts/build/registry.mjs`.
15
+ *
16
+ * Run: node --test packages/core/scripts/build/registry/__tests__/scope-registry.test.mjs
17
+ */
18
+
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+
22
+ import { generateScopeRegistry } from '../generators/scope-registry.mjs'
23
+
24
+ test('imports ENTITY_REGISTRY and AVAILABLE_ROLES/PERMISSIONS_BY_ROLE from sibling generated registries', () => {
25
+ const out = generateScopeRegistry([], {})
26
+ assert.match(out, /import \{ ENTITY_REGISTRY \} from '\.\/entity-registry'/)
27
+ assert.match(out, /import \{ AVAILABLE_ROLES, PERMISSIONS_BY_ROLE \} from '\.\/permissions-registry'/)
28
+ })
29
+
30
+ test('does not reintroduce the old generation-time entities/config computation', () => {
31
+ const out = generateScopeRegistry([{ name: 'ignored', api: { endpoints: { create: true } } }], { some: 'config' })
32
+ // The old bug read entity.api.endpoints/entity.features/entity.fields directly —
33
+ // none of those property paths should appear in the emitted output at all,
34
+ // since the real computation now reads ENTITY_REGISTRY's real EntityConfig.
35
+ assert.doesNotMatch(out, /entity\.api\.endpoints/)
36
+ assert.doesNotMatch(out, /entity\.features/)
37
+ })
38
+
39
+ test('base scopes are empty — nothing granted irrespective of role', () => {
40
+ const out = generateScopeRegistry([], {})
41
+ assert.match(out, /base:\s*\[\]/)
42
+ })
43
+
44
+ test('role scopes are computed from PERMISSIONS_BY_ROLE per AVAILABLE_ROLES, not hardcoded role names', () => {
45
+ const out = generateScopeRegistry([], {})
46
+ assert.match(out, /for \(const role of AVAILABLE_ROLES\)/)
47
+ assert.match(out, /const perms = PERMISSIONS_BY_ROLE\[role\]/)
48
+ // The old hardcoded roleScopes literal (superadmin/admin/manager/member keys,
49
+ // referencing a nonexistent "products" entity) must be gone.
50
+ assert.doesNotMatch(out, /'manager'/)
51
+ assert.doesNotMatch(out, /products:write/)
52
+ assert.doesNotMatch(out, /products:delete/)
53
+ })
54
+
55
+ test('read/write/delete scopes are derived from list|read / create|update / delete permissions', () => {
56
+ const out = generateScopeRegistry([], {})
57
+ assert.match(out, /\$\{slug\}\.list.*Permission.*\|\|.*perms\?\.has\(`\$\{slug\}\.read`/)
58
+ assert.match(out, /\$\{slug\}\.create.*Permission.*\|\|.*perms\?\.has\(`\$\{slug\}\.update`/)
59
+ assert.match(out, /\$\{slug\}\.delete.*Permission/)
60
+ assert.match(out, /scopes\.push\(`\$\{slug\}:read`\)/)
61
+ assert.match(out, /scopes\.push\(`\$\{slug\}:write`\)/)
62
+ assert.match(out, /scopes\.push\(`\$\{slug\}:delete`\)/)
63
+ })
64
+
65
+ test('guards against ChildEntityDefinition entries (no access/slug) in ENTITY_REGISTRY', () => {
66
+ const out = generateScopeRegistry([], {})
67
+ assert.match(out, /if \(!cfg\?\.access\?\.api \|\| !cfg\.slug\) return null/)
68
+ })
69
+
70
+ test('flag scopes and restrictions are preserved unchanged', () => {
71
+ const out = generateScopeRegistry([], {})
72
+ assert.match(out, /beta_tester/)
73
+ assert.match(out, /vip:features/)
74
+ assert.match(out, /"remove": \[\s*"delete",\s*"admin"\s*\]/)
75
+ assert.match(out, /"allow_only": \[\s*"read",\s*"tasks:write"\s*\]/)
76
+ })
77
+
78
+ test('superadmin is not hardcoded into SCOPE_CONFIG.roles — handled as a global-role bypass elsewhere', () => {
79
+ const out = generateScopeRegistry([], {})
80
+ assert.doesNotMatch(out, /'superadmin':\s*\[/)
81
+ assert.match(out, /Superadmin is a GLOBAL role/)
82
+ })
83
+
84
+ test('is a pure function of its output shape — always emits both SCOPE_CONFIG and API_CONFIG exports', () => {
85
+ const out = generateScopeRegistry([], {})
86
+ assert.match(out, /export const SCOPE_CONFIG: ScopeConfig = \{/)
87
+ assert.match(out, /export const API_CONFIG: ApiConfig = \{/)
88
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * MCP Override Discovery
3
+ *
4
+ * Discovers `entities/<slug>/mcp.ts` files for the active theme — the
5
+ * extension point a theme uses to customize how one entity is exposed by the
6
+ * MCP engine (@nextsparkjs/core/lib/mcp): exclude it, exclude specific
7
+ * operations, relax required fields, transform input/output, or add extra
8
+ * (non-CRUD) tools.
9
+ *
10
+ * Mirrors discoverApiPresets's discoverEntityFolders (same
11
+ * `{theme}/entities/<slug>/` scan, same theme-only scope) with one
12
+ * difference: `mcp.ts` sits at the entity folder root (a sibling of `api/`,
13
+ * `migrations/`, `messages/`), not nested under `api/` like `presets.ts`.
14
+ *
15
+ * Unlike the presets discovery, this module does NOT parse file contents —
16
+ * an McpEntityOverride exports real functions (transformInput, extraTools),
17
+ * which can't be recovered by regex/brace-counting. The generator instead
18
+ * emits a literal `import` statement per discovered file (same approach as
19
+ * entity-registry.mjs), and the override object itself is resolved by the
20
+ * TypeScript compiler/bundler at build time, not by this script.
21
+ *
22
+ * @module core/scripts/build/registry/discovery/mcp-overrides
23
+ */
24
+
25
+ import { readdir } from 'fs/promises'
26
+ import { join } from 'path'
27
+ import { existsSync } from 'fs'
28
+
29
+ import { log, verbose } from '../../../utils/index.mjs'
30
+
31
+ /**
32
+ * Discover MCP entity overrides for the active theme.
33
+ * @param {object} config - Configuration object from getConfig()
34
+ * @returns {Promise<Array<{slug: string, importPath: string, themeName: string}>>}
35
+ */
36
+ export async function discoverMcpOverrides(config) {
37
+ const overrides = []
38
+ const themeName = config.activeTheme
39
+
40
+ if (!themeName) {
41
+ verbose('[mcp-overrides] No active theme configured — skipping MCP override discovery')
42
+ return overrides
43
+ }
44
+
45
+ const entitiesDir = join(config.themesDir, themeName, 'entities')
46
+ if (!existsSync(entitiesDir)) {
47
+ verbose(`[mcp-overrides] No entities directory found for theme "${themeName}"`)
48
+ return overrides
49
+ }
50
+
51
+ try {
52
+ const entries = await readdir(entitiesDir, { withFileTypes: true })
53
+ const entityDirs = entries.filter((entry) => entry.isDirectory())
54
+
55
+ for (const dir of entityDirs) {
56
+ const entityName = dir.name
57
+ const mcpPath = join(entitiesDir, entityName, 'mcp.ts')
58
+ if (!existsSync(mcpPath)) continue
59
+
60
+ overrides.push({
61
+ slug: entityName,
62
+ importPath: `@/contents/themes/${themeName}/entities/${entityName}/mcp`,
63
+ themeName,
64
+ })
65
+ verbose(`[mcp-overrides] MCP override discovered: ${entityName} -> ${mcpPath}`)
66
+ }
67
+ } catch (error) {
68
+ log(`[mcp-overrides] Error discovering MCP overrides: ${error.message}`, 'error')
69
+ }
70
+
71
+ return overrides
72
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for discoverMcpOverrides.
3
+ *
4
+ * Run: node --test packages/core/scripts/build/registry/discovery/mcp-overrides.test.mjs
5
+ */
6
+
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+
13
+ import { discoverMcpOverrides } from './mcp-overrides.mjs'
14
+
15
+ async function makeThemeFixture(entities) {
16
+ const root = await mkdtemp(join(tmpdir(), 'mcp-overrides-test-'))
17
+ const themesDir = join(root, 'themes')
18
+ const themeName = 'testtheme'
19
+ const entitiesDir = join(themesDir, themeName, 'entities')
20
+
21
+ for (const [slug, files] of Object.entries(entities)) {
22
+ const entityDir = join(entitiesDir, slug)
23
+ await mkdir(entityDir, { recursive: true })
24
+ for (const file of files) {
25
+ await writeFile(join(entityDir, file), '// fixture\n')
26
+ }
27
+ }
28
+
29
+ return { root, themesDir, themeName }
30
+ }
31
+
32
+ test('returns an empty array when there is no active theme', async () => {
33
+ const result = await discoverMcpOverrides({ activeTheme: undefined, themesDir: '/nonexistent' })
34
+ assert.deepEqual(result, [])
35
+ })
36
+
37
+ test('returns an empty array when the theme has no entities directory', async () => {
38
+ const root = await mkdtemp(join(tmpdir(), 'mcp-overrides-test-'))
39
+ const result = await discoverMcpOverrides({ activeTheme: 'ghost', themesDir: join(root, 'themes') })
40
+ assert.deepEqual(result, [])
41
+ await rm(root, { recursive: true, force: true })
42
+ })
43
+
44
+ test('finds mcp.ts at the entity folder root, not nested under api/', async () => {
45
+ const { root, themesDir, themeName } = await makeThemeFixture({
46
+ customers: ['customers.config.ts', 'mcp.ts'],
47
+ })
48
+ const result = await discoverMcpOverrides({ activeTheme: themeName, themesDir })
49
+ assert.equal(result.length, 1)
50
+ assert.equal(result[0].slug, 'customers')
51
+ assert.equal(result[0].importPath, `@/contents/themes/${themeName}/entities/customers/mcp`)
52
+ await rm(root, { recursive: true, force: true })
53
+ })
54
+
55
+ test('skips entities that have no mcp.ts', async () => {
56
+ const { root, themesDir, themeName } = await makeThemeFixture({
57
+ customers: ['customers.config.ts', 'mcp.ts'],
58
+ tasks: ['tasks.config.ts'],
59
+ })
60
+ const result = await discoverMcpOverrides({ activeTheme: themeName, themesDir })
61
+ assert.deepEqual(result.map((r) => r.slug), ['customers'])
62
+ await rm(root, { recursive: true, force: true })
63
+ })
64
+
65
+ test('does NOT match a preset-style entities/<slug>/api/mcp.ts (wrong location)', async () => {
66
+ const root = await mkdtemp(join(tmpdir(), 'mcp-overrides-test-'))
67
+ const themesDir = join(root, 'themes')
68
+ const themeName = 'testtheme'
69
+ await mkdir(join(themesDir, themeName, 'entities', 'customers', 'api'), { recursive: true })
70
+ await writeFile(join(themesDir, themeName, 'entities', 'customers', 'api', 'mcp.ts'), '// wrong spot\n')
71
+
72
+ const result = await discoverMcpOverrides({ activeTheme: themeName, themesDir })
73
+ assert.deepEqual(result, [])
74
+ await rm(root, { recursive: true, force: true })
75
+ })
76
+
77
+ test('discovers multiple entity overrides in the same theme', async () => {
78
+ const { root, themesDir, themeName } = await makeThemeFixture({
79
+ customers: ['mcp.ts'],
80
+ tasks: ['mcp.ts'],
81
+ posts: [],
82
+ })
83
+ const result = await discoverMcpOverrides({ activeTheme: themeName, themesDir })
84
+ assert.deepEqual(result.map((r) => r.slug).sort(), ['customers', 'tasks'])
85
+ await rm(root, { recursive: true, force: true })
86
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * MCP Override Registry Generator
3
+ *
4
+ * Generates mcp-registry.ts: a literal-import map of discovered
5
+ * `entities/<slug>/mcp.ts` files (see discovery/mcp-overrides.mjs), consumed
6
+ * by the default `/api/mcp` route template to build the `overrides` option
7
+ * passed to `createMcpEngine`. Follows entity-registry.mjs's literal-import
8
+ * pattern (not the regex-parsing approach used for api-presets.ts) because
9
+ * an McpEntityOverride exports real functions — transformInput, extraTools —
10
+ * that can't be recovered from source text.
11
+ *
12
+ * @module core/scripts/build/registry/generators/mcp-registry
13
+ */
14
+
15
+ import { join } from 'path'
16
+ import { convertCorePath } from '../config.mjs'
17
+
18
+ /**
19
+ * Generate the MCP override registry file.
20
+ * @param {Array<{slug: string, importPath: string, themeName: string}>} mcpOverrides - Discovered overrides
21
+ * @param {object} config - Configuration object from getConfig()
22
+ * @returns {string} Generated TypeScript content
23
+ */
24
+ export function generateMcpRegistry(mcpOverrides, config) {
25
+ const outputFilePath = join(config.outputDir, 'mcp-registry.ts')
26
+
27
+ const importName = (slug) => `${toPascalCase(slug)}McpOverride`
28
+
29
+ const imports = mcpOverrides
30
+ .map((entry) => `import ${importName(entry.slug)} from '${entry.importPath}'`)
31
+ .join('\n')
32
+
33
+ const entries = mcpOverrides
34
+ .map((entry) => ` '${entry.slug}': ${importName(entry.slug)},`)
35
+ .join('\n')
36
+
37
+ return `/**
38
+ * Auto-generated MCP Override Registry
39
+ *
40
+ * Generated at: ${new Date().toISOString()}
41
+ * MCP overrides discovered: ${mcpOverrides.length}
42
+ *
43
+ * DO NOT EDIT - This file is auto-generated by scripts/build/registry.mjs
44
+ */
45
+
46
+ ${imports}
47
+ import type { McpEntityOverride } from '${convertCorePath('@/core/lib/mcp', outputFilePath, config)}'
48
+
49
+ export const MCP_OVERRIDES: Record<string, McpEntityOverride> = {
50
+ ${entries}
51
+ }
52
+ `
53
+ }
54
+
55
+ function toPascalCase(slug) {
56
+ return slug
57
+ .split(/[-_/]+/)
58
+ .filter(Boolean)
59
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
60
+ .join('')
61
+ }