@nextsparkjs/core 0.1.0-beta.183 → 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.
- package/dist/components/auth/forms/LoginForm.d.ts.map +1 -1
- package/dist/components/auth/forms/LoginForm.js +8 -5
- package/dist/hooks/useAuth.d.ts +74 -25
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useValidationSchemas.d.ts +7 -7
- package/dist/lib/api/auth/dual-auth.d.ts +7 -0
- package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
- package/dist/lib/api/auth/dual-auth.js +1 -1
- package/dist/lib/api/auth.d.ts +20 -3
- package/dist/lib/api/auth.d.ts.map +1 -1
- package/dist/lib/api/auth.js +37 -19
- package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
- package/dist/lib/api/entity/generic-handler.js +19 -22
- package/dist/lib/auth.d.ts +1 -3057
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/billing/schema.d.ts +1 -1
- package/dist/lib/mcp/audit.d.ts +17 -0
- package/dist/lib/mcp/audit.d.ts.map +1 -0
- package/dist/lib/mcp/audit.js +48 -0
- package/dist/lib/mcp/engine.d.ts +28 -0
- package/dist/lib/mcp/engine.d.ts.map +1 -0
- package/dist/lib/mcp/engine.js +158 -0
- package/dist/lib/mcp/errors.d.ts +16 -0
- package/dist/lib/mcp/errors.d.ts.map +1 -0
- package/dist/lib/mcp/errors.js +66 -0
- package/dist/lib/mcp/executor.d.ts +15 -0
- package/dist/lib/mcp/executor.d.ts.map +1 -0
- package/dist/lib/mcp/executor.js +63 -0
- package/dist/lib/mcp/index.d.ts +9 -0
- package/dist/lib/mcp/index.d.ts.map +1 -0
- package/dist/lib/mcp/index.js +32 -0
- package/dist/lib/mcp/schema-builder.d.ts +45 -0
- package/dist/lib/mcp/schema-builder.d.ts.map +1 -0
- package/dist/lib/mcp/schema-builder.js +212 -0
- package/dist/lib/mcp/tool-generator.d.ts +31 -0
- package/dist/lib/mcp/tool-generator.d.ts.map +1 -0
- package/dist/lib/mcp/tool-generator.js +271 -0
- package/dist/lib/mcp/transport.d.ts +31 -0
- package/dist/lib/mcp/transport.d.ts.map +1 -0
- package/dist/lib/mcp/transport.js +58 -0
- package/dist/lib/mcp/types.d.ts +184 -0
- package/dist/lib/mcp/types.d.ts.map +1 -0
- package/dist/lib/mcp/types.js +11 -0
- package/dist/messages/de/auth.json +2 -0
- package/dist/messages/de/index.d.ts +2 -0
- package/dist/messages/de/index.d.ts.map +1 -1
- package/dist/messages/en/auth.json +3 -1
- package/dist/messages/en/index.d.ts +2 -0
- package/dist/messages/en/index.d.ts.map +1 -1
- package/dist/messages/es/auth.json +2 -0
- package/dist/messages/es/index.d.ts +2 -0
- package/dist/messages/es/index.d.ts.map +1 -1
- package/dist/messages/fr/auth.json +2 -0
- package/dist/messages/fr/index.d.ts +2 -0
- package/dist/messages/fr/index.d.ts.map +1 -1
- package/dist/messages/it/auth.json +2 -0
- package/dist/messages/it/index.d.ts +2 -0
- package/dist/messages/it/index.d.ts.map +1 -1
- package/dist/messages/pt/auth.json +2 -0
- package/dist/messages/pt/index.d.ts +2 -0
- package/dist/messages/pt/index.d.ts.map +1 -1
- package/dist/styles/classes.json +1 -1
- package/dist/styles/ui.css +1 -1
- package/dist/templates/app/api/mcp/route.ts +173 -0
- package/dist/templates/app/api/v1/api-keys/route.ts +15 -3
- package/dist/templates/app/layout.tsx +3 -0
- package/package.json +8 -3
- package/scripts/build/registry/__tests__/mcp-registry.test.mjs +64 -0
- package/scripts/build/registry/__tests__/scope-registry.test.mjs +88 -0
- package/scripts/build/registry/discovery/mcp-overrides.mjs +72 -0
- package/scripts/build/registry/discovery/mcp-overrides.test.mjs +86 -0
- package/scripts/build/registry/generators/mcp-registry.mjs +61 -0
- package/scripts/build/registry/generators/scope-registry.mjs +133 -88
- package/scripts/build/registry.mjs +8 -4
- package/templates/app/api/mcp/route.ts +173 -0
- package/templates/app/api/v1/api-keys/route.ts +15 -3
- package/templates/app/layout.tsx +3 -0
- package/tests/jest/__mocks__/@nextsparkjs/registries/scope-registry.ts +56 -6
- package/dist/lib/auth-client.d.ts +0 -3595
- 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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
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
|
+
}
|