create-nextblock 0.14.5 → 0.14.6
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/package.json +1 -1
- package/templates/nextblock-template/AGENTS.md +9 -0
- package/templates/nextblock-template/CLAUDE.md +1 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +96 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +31 -1
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +5 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
|
|
3
|
+
# This is NOT the Next.js you know
|
|
4
|
+
|
|
5
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
6
|
+
|
|
7
|
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
8
|
+
|
|
9
|
+
<!-- END:nextjs-agent-rules -->
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -6513,6 +6513,100 @@ SELECT
|
|
|
6513
6513
|
ON CONFLICT (product_id, version) DO NOTHING;
|
|
6514
6514
|
|
|
6515
6515
|
|
|
6516
|
+
-- >>> FROM: 00000000000017_cortex_ai_mcp_server.sql <<<
|
|
6517
|
+
-- Cortex AI MCP (Model Context Protocol) server access.
|
|
6518
|
+
--
|
|
6519
|
+
-- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the
|
|
6520
|
+
-- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,
|
|
6521
|
+
-- Cursor, VS Code). Two pieces:
|
|
6522
|
+
--
|
|
6523
|
+
-- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the
|
|
6524
|
+
-- SHA-256 hash of the token, never the token itself: the plaintext is shown
|
|
6525
|
+
-- to the admin exactly once at mint time and is unrecoverable afterwards, so
|
|
6526
|
+
-- a database leak cannot be replayed against the MCP endpoint. \`token_prefix\`
|
|
6527
|
+
-- is the non-secret leading fragment kept purely so the UI can tell two tokens
|
|
6528
|
+
-- apart in a list.
|
|
6529
|
+
--
|
|
6530
|
+
-- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the
|
|
6531
|
+
-- server on/off switch and the localhost-trust flag. It is added to all four
|
|
6532
|
+
-- site_settings policies so only authenticated ADMINs can read or write it;
|
|
6533
|
+
-- the MCP route itself reads it through the service-role client, which
|
|
6534
|
+
-- bypasses RLS.
|
|
6535
|
+
--
|
|
6536
|
+
-- Forward-only. Recreates the four site_settings policies idempotently, preserving
|
|
6537
|
+
-- every key already in each policy's sensitive array (note that
|
|
6538
|
+
-- language_detection_settings stays anon-READABLE and so is absent from the SELECT
|
|
6539
|
+
-- policy, exactly as migration 00000000000012 left it).
|
|
6540
|
+
|
|
6541
|
+
CREATE TABLE IF NOT EXISTS public.mcp_access_tokens (
|
|
6542
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
6543
|
+
name text NOT NULL,
|
|
6544
|
+
-- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single
|
|
6545
|
+
-- indexed equality probe and duplicate mints are impossible.
|
|
6546
|
+
token_hash text NOT NULL UNIQUE,
|
|
6547
|
+
-- Non-secret display fragment, e.g. "nbmcp_a1b2c3d4". Never enough to authenticate.
|
|
6548
|
+
token_prefix text NOT NULL,
|
|
6549
|
+
-- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.
|
|
6550
|
+
scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],
|
|
6551
|
+
created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
|
|
6552
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
6553
|
+
last_used_at timestamptz,
|
|
6554
|
+
expires_at timestamptz,
|
|
6555
|
+
revoked_at timestamptz
|
|
6556
|
+
);
|
|
6557
|
+
|
|
6558
|
+
COMMENT ON TABLE public.mcp_access_tokens IS
|
|
6559
|
+
'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';
|
|
6560
|
+
|
|
6561
|
+
CREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx
|
|
6562
|
+
ON public.mcp_access_tokens (token_hash);
|
|
6563
|
+
|
|
6564
|
+
-- Orders the admin token list newest-first without a sort.
|
|
6565
|
+
CREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx
|
|
6566
|
+
ON public.mcp_access_tokens (created_at DESC);
|
|
6567
|
+
|
|
6568
|
+
ALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;
|
|
6569
|
+
|
|
6570
|
+
-- Tokens are credentials: admin-only, with no anon or WRITER access at all. The
|
|
6571
|
+
-- MCP route verifies them with the service-role client, which bypasses RLS.
|
|
6572
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
|
|
6573
|
+
CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
|
|
6574
|
+
FOR SELECT TO authenticated
|
|
6575
|
+
USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
|
|
6576
|
+
|
|
6577
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
|
|
6578
|
+
CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
|
|
6579
|
+
FOR INSERT TO authenticated
|
|
6580
|
+
WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
|
|
6581
|
+
|
|
6582
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
|
|
6583
|
+
CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
|
|
6584
|
+
FOR UPDATE TO authenticated
|
|
6585
|
+
USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
6586
|
+
WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
|
|
6587
|
+
|
|
6588
|
+
DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
|
|
6589
|
+
CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
|
|
6590
|
+
FOR DELETE TO authenticated
|
|
6591
|
+
USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
|
|
6592
|
+
|
|
6593
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;
|
|
6594
|
+
GRANT ALL ON public.mcp_access_tokens TO service_role;
|
|
6595
|
+
|
|
6596
|
+
-- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).
|
|
6597
|
+
DROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;
|
|
6598
|
+
CREATE POLICY site_settings_read_policy ON public.site_settings FOR SELECT USING (((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT auth.role() AS role) = 'authenticated'::text) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
6599
|
+
|
|
6600
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
6601
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
6602
|
+
|
|
6603
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
6604
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
6605
|
+
|
|
6606
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
6607
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
6608
|
+
|
|
6609
|
+
|
|
6516
6610
|
-- Step D: Record the applied migrations in history (truncated in Step B) so
|
|
6517
6611
|
-- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
|
|
6518
6612
|
INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
|
|
@@ -6532,7 +6626,8 @@ ON CONFLICT (product_id, version) DO NOTHING;
|
|
|
6532
6626
|
('00000000000013', 'youtube_nocookie_embeds'),
|
|
6533
6627
|
('00000000000014', 'site_themes'),
|
|
6534
6628
|
('00000000000015', 'scheduled_publishing'),
|
|
6535
|
-
('00000000000016', 'product_revisions_and_revision_baseline')
|
|
6629
|
+
('00000000000016', 'product_revisions_and_revision_baseline'),
|
|
6630
|
+
('00000000000017', 'cortex_ai_mcp_server')
|
|
6536
6631
|
ON CONFLICT (version) DO NOTHING;
|
|
6537
6632
|
|
|
6538
6633
|
-- Step E: Anchor preserved profiles
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { revalidatePath } from 'next/cache';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createClient,
|
|
5
|
+
getServiceRoleSupabaseClient,
|
|
6
|
+
verifyPackageOnline,
|
|
7
|
+
} from '@nextblock-cms/db/server';
|
|
8
|
+
import {
|
|
9
|
+
CORTEX_AI_PACKAGE_ID,
|
|
10
|
+
handleCortexMcpMessage,
|
|
11
|
+
isLocalhostHost,
|
|
12
|
+
parseBearerToken,
|
|
13
|
+
resolveCortexAiMcpSettings,
|
|
14
|
+
shouldTrustLocalMcpRequest,
|
|
15
|
+
touchCortexAiMcpToken,
|
|
16
|
+
verifyCortexAiMcpToken,
|
|
17
|
+
type CortexAiMcpScope,
|
|
18
|
+
type CortexMcpToolContext,
|
|
19
|
+
type JsonRpcMessage,
|
|
20
|
+
} from '@nextblock-cms/cortex';
|
|
21
|
+
|
|
22
|
+
import { validateBlockContent } from '../../../lib/blocks/blockRegistry';
|
|
23
|
+
import { importExternalImageToMedia } from '../../cms/media/import-external-image';
|
|
24
|
+
import { captureRevisionBaseline, commitRevisionFromBaseline } from '../../cms/revisions/service';
|
|
25
|
+
import type { AnyFullContent } from '../../cms/revisions/utils';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Model Context Protocol server endpoint.
|
|
29
|
+
*
|
|
30
|
+
* Exposes the Cortex AI tool registry over MCP Streamable HTTP so external clients
|
|
31
|
+
* (Claude Code, Claude Desktop, Cursor, VS Code) can operate this CMS with the same
|
|
32
|
+
* typed, validated tools the in-app dashboard agent uses. The protocol itself lives
|
|
33
|
+
* in `@nextblock-cms/cortex` (`mcp-server.ts`); this file is the HTTP shim plus auth.
|
|
34
|
+
*
|
|
35
|
+
* Node runtime, not Edge: the tool executors reach `node:crypto`, `sharp` (via the
|
|
36
|
+
* media importer) and the service-role Supabase client.
|
|
37
|
+
*/
|
|
38
|
+
export const runtime = 'nodejs';
|
|
39
|
+
export const dynamic = 'force-dynamic';
|
|
40
|
+
|
|
41
|
+
const SERVER_VERSION = '1.0.0';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Confirmation is skipped for MCP callers, deliberately.
|
|
45
|
+
*
|
|
46
|
+
* The in-app agent's two-phase confirm works by matching a phrase in the user's *next
|
|
47
|
+
* chat message*, which has no analogue in MCP — the model calls a tool and gets a
|
|
48
|
+
* result, with no channel to carry a human phrase back. Every MCP host already gates
|
|
49
|
+
* tool calls behind its own approval UI, so the confirmation would be a second prompt
|
|
50
|
+
* the protocol cannot satisfy, and leaving it on would simply make every mutating
|
|
51
|
+
* tool return a preview forever. The real control for MCP is the token scope: a
|
|
52
|
+
* read-only token never sees a mutating tool at all.
|
|
53
|
+
*/
|
|
54
|
+
const MCP_SKIP_CONFIRMATION = true;
|
|
55
|
+
|
|
56
|
+
type McpAuth = {
|
|
57
|
+
actorUserId: string | null;
|
|
58
|
+
scopes: CortexAiMcpScope[];
|
|
59
|
+
source: 'admin-session' | 'localhost' | 'token';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function importExternalImageForMcp(input: {
|
|
63
|
+
url: string;
|
|
64
|
+
altText?: string;
|
|
65
|
+
}): Promise<{ id: string } | { error: string }> {
|
|
66
|
+
const result = await importExternalImageToMedia({ altText: input.altText, url: input.url });
|
|
67
|
+
|
|
68
|
+
if ('error' in result) {
|
|
69
|
+
return { error: result.error };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { id: result.media.id };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mirrors the global-agent route so MCP writes land in Revision History like any other edit. */
|
|
76
|
+
function createMcpRevisionRecorder(authorId: string | null) {
|
|
77
|
+
return async function recordRevision(input: {
|
|
78
|
+
baseline?: unknown;
|
|
79
|
+
contentType: 'page' | 'post' | 'product';
|
|
80
|
+
entityId: number | string;
|
|
81
|
+
phase: 'capture' | 'commit';
|
|
82
|
+
}): Promise<unknown> {
|
|
83
|
+
if (input.phase === 'capture') {
|
|
84
|
+
return captureRevisionBaseline(input.contentType, input.entityId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await commitRevisionFromBaseline(
|
|
88
|
+
input.contentType,
|
|
89
|
+
input.entityId,
|
|
90
|
+
authorId,
|
|
91
|
+
(input.baseline ?? null) as AnyFullContent | null
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if ('error' in result) {
|
|
95
|
+
console.error('Cortex AI MCP: revision not recorded —', result.error);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reject cross-origin browser calls (DNS-rebinding defence, required by the spec).
|
|
104
|
+
*
|
|
105
|
+
* Only enforced when an `Origin` header is present: native MCP clients are not
|
|
106
|
+
* browsers and send none, so requiring one would lock out every real caller.
|
|
107
|
+
*/
|
|
108
|
+
function isOriginAllowed(request: Request): boolean {
|
|
109
|
+
const origin = request.headers.get('origin');
|
|
110
|
+
|
|
111
|
+
if (!origin) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let originHost: string;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
originHost = new URL(origin).host;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isLocalhostHost(originHost)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const host = request.headers.get('host');
|
|
128
|
+
|
|
129
|
+
if (host && originHost.toLowerCase() === host.toLowerCase()) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const configuredUrl = process.env.NEXT_PUBLIC_URL;
|
|
134
|
+
|
|
135
|
+
if (configuredUrl) {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(configuredUrl).host.toLowerCase() === originHost.toLowerCase();
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Establish who is calling.
|
|
148
|
+
*
|
|
149
|
+
* Three accepted paths, in priority order:
|
|
150
|
+
* 1. A bearer token from `mcp_access_tokens` — the path every external client uses.
|
|
151
|
+
* 2. An authenticated ADMIN cookie session — lets the dashboard's own "Test
|
|
152
|
+
* connection" button reach the endpoint without minting a token first.
|
|
153
|
+
* 3. Loopback in development, when the operator has left that setting on.
|
|
154
|
+
*/
|
|
155
|
+
async function authenticateMcpRequest(request: Request): Promise<McpAuth | null> {
|
|
156
|
+
const serviceClient = getServiceRoleSupabaseClient();
|
|
157
|
+
const settings = await resolveCortexAiMcpSettings(serviceClient);
|
|
158
|
+
|
|
159
|
+
if (!settings.enabled) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bearer = parseBearerToken(request.headers.get('authorization'));
|
|
164
|
+
|
|
165
|
+
if (bearer) {
|
|
166
|
+
const verification = await verifyCortexAiMcpToken(serviceClient, bearer);
|
|
167
|
+
|
|
168
|
+
if (!verification.valid) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bookkeeping only — never block the call on it.
|
|
173
|
+
void touchCortexAiMcpToken(serviceClient, verification.token.id);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
actorUserId: verification.token.created_by,
|
|
177
|
+
scopes: verification.scopes,
|
|
178
|
+
source: 'token',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const adminUserId = await resolveAdminSessionUserId();
|
|
183
|
+
|
|
184
|
+
if (adminUserId) {
|
|
185
|
+
return { actorUserId: adminUserId, scopes: ['read', 'write'], source: 'admin-session' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (shouldTrustLocalMcpRequest({ hostHeader: request.headers.get('host'), settings })) {
|
|
189
|
+
return { actorUserId: null, scopes: ['read', 'write'], source: 'localhost' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveAdminSessionUserId(): Promise<string | null> {
|
|
196
|
+
try {
|
|
197
|
+
const supabase = createClient();
|
|
198
|
+
const {
|
|
199
|
+
data: { user },
|
|
200
|
+
} = await supabase.auth.getUser();
|
|
201
|
+
|
|
202
|
+
if (!user) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { data: profile } = await supabase
|
|
207
|
+
.from('profiles')
|
|
208
|
+
.select('role')
|
|
209
|
+
.eq('id', user.id)
|
|
210
|
+
.single();
|
|
211
|
+
|
|
212
|
+
return profile?.role === 'ADMIN' ? user.id : null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildToolContext(auth: McpAuth): CortexMcpToolContext {
|
|
219
|
+
return {
|
|
220
|
+
actorUserId: auth.actorUserId,
|
|
221
|
+
importExternalImage: importExternalImageForMcp,
|
|
222
|
+
// No open editor over MCP: tools that need a target take it in their arguments
|
|
223
|
+
// (`cmsTarget`, `slug`, `entityId`) rather than inheriting one from a UI.
|
|
224
|
+
pageContext: null,
|
|
225
|
+
recordRevision: createMcpRevisionRecorder(auth.actorUserId),
|
|
226
|
+
revalidatePath,
|
|
227
|
+
skipConfirmation: MCP_SKIP_CONFIRMATION,
|
|
228
|
+
supabase: getServiceRoleSupabaseClient(),
|
|
229
|
+
validateBlockContent,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const JSON_HEADERS = {
|
|
234
|
+
'Cache-Control': 'no-store',
|
|
235
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
236
|
+
} as const;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 401 for an unauthenticated caller.
|
|
240
|
+
*
|
|
241
|
+
* The `WWW-Authenticate` value is intentionally bare. Adding a `resource_metadata`
|
|
242
|
+
* parameter would advertise RFC 9728 OAuth discovery, and Claude Code responds to
|
|
243
|
+
* that by starting an OAuth flow — which dead-ends against a static-token server.
|
|
244
|
+
* A plain challenge tells the client "send a bearer token" and nothing more.
|
|
245
|
+
*/
|
|
246
|
+
function unauthorized(message: string): Response {
|
|
247
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
248
|
+
headers: {
|
|
249
|
+
...JSON_HEADERS,
|
|
250
|
+
'WWW-Authenticate': 'Bearer realm="NextBlock Cortex AI MCP"',
|
|
251
|
+
},
|
|
252
|
+
status: 401,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function POST(request: Request): Promise<Response> {
|
|
257
|
+
if (!isOriginAllowed(request)) {
|
|
258
|
+
return new Response(JSON.stringify({ error: 'Origin not allowed.' }), {
|
|
259
|
+
headers: JSON_HEADERS,
|
|
260
|
+
status: 403,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const isCortexAiActive = await verifyPackageOnline(CORTEX_AI_PACKAGE_ID);
|
|
265
|
+
|
|
266
|
+
if (!isCortexAiActive) {
|
|
267
|
+
return new Response(
|
|
268
|
+
JSON.stringify({ error: 'NextBlock Cortex AI is not active for this workspace.' }),
|
|
269
|
+
{ headers: JSON_HEADERS, status: 403 }
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const auth = await authenticateMcpRequest(request);
|
|
274
|
+
|
|
275
|
+
if (!auth) {
|
|
276
|
+
return unauthorized(
|
|
277
|
+
'A valid NextBlock MCP access token is required. Generate one in CMS Settings → Cortex AI, and confirm the MCP server is enabled there.'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let message: JsonRpcMessage;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
message = (await request.json()) as JsonRpcMessage;
|
|
285
|
+
} catch {
|
|
286
|
+
return new Response(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
error: { code: -32700, message: 'Parse error: request body is not valid JSON.' },
|
|
289
|
+
id: null,
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
}),
|
|
292
|
+
{ headers: JSON_HEADERS, status: 400 }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const response = await handleCortexMcpMessage(message, {
|
|
297
|
+
context: buildToolContext(auth),
|
|
298
|
+
scopes: auth.scopes,
|
|
299
|
+
serverVersion: SERVER_VERSION,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Notifications and responses: 202 Accepted with no body. Returning a JSON-RPC
|
|
303
|
+
// envelope for a message that carried no `id` desyncs strict clients.
|
|
304
|
+
if (response.body === null) {
|
|
305
|
+
return new Response(null, { status: response.status });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return new Response(JSON.stringify(response.body), {
|
|
309
|
+
headers: JSON_HEADERS,
|
|
310
|
+
status: response.status,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The optional server→client SSE stream.
|
|
316
|
+
*
|
|
317
|
+
* This server never initiates requests or pushes unsolicited notifications — every
|
|
318
|
+
* response is returned inline on the POST — so there is nothing to stream. The spec
|
|
319
|
+
* explicitly permits answering the GET with 405 in that case.
|
|
320
|
+
*/
|
|
321
|
+
export function GET(): Response {
|
|
322
|
+
return new Response(
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
error:
|
|
325
|
+
'This MCP endpoint does not offer a server-initiated SSE stream. Send JSON-RPC messages via POST.',
|
|
326
|
+
}),
|
|
327
|
+
{ headers: { ...JSON_HEADERS, Allow: 'POST, DELETE, OPTIONS' }, status: 405 }
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Session termination. The server is stateless, so there is no session to tear down. */
|
|
332
|
+
export function DELETE(): Response {
|
|
333
|
+
return new Response(null, { status: 204 });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function OPTIONS(): Response {
|
|
337
|
+
return new Response(null, {
|
|
338
|
+
headers: {
|
|
339
|
+
'Access-Control-Allow-Headers':
|
|
340
|
+
'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',
|
|
341
|
+
'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
|
|
342
|
+
Allow: 'POST, DELETE, OPTIONS',
|
|
343
|
+
},
|
|
344
|
+
status: 204,
|
|
345
|
+
});
|
|
346
|
+
}
|