strapi-plugin-mcp-chat 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,30 @@
1
1
  import { z } from '@strapi/utils';
2
- import type { StrapiMcpToolModule } from '../types';
2
+ import { defineTool } from '../define';
3
3
  import { createContentTools } from '../../content-tools';
4
4
 
5
- const tool: StrapiMcpToolModule = {
6
- register(registerTool) {
7
- registerTool({
8
- name: 'mcp_chat_traduzir',
9
- title: 'Translate localized content',
10
- description:
11
- 'Translate localized content into one or more languages. Creates missing locales, translates field by field (long text is split and reassembled, never overflows) and publishes. Without uid/documentId, translates ALL localized content-types. Handles many locales at once.',
12
- resolveInputSchema: () =>
13
- z.object({
14
- target_locales: z.array(z.string()).min(1),
15
- source_locale: z.string().optional(),
16
- uid: z.string().optional(),
17
- documentId: z.string().optional(),
18
- publish: z.boolean().optional(),
19
- }),
20
- resolveOutputSchema: () =>
21
- z.object({
22
- ok: z.boolean().optional(),
23
- source: z.string().optional(),
24
- por_locale: z.array(z.any()).optional(),
25
- erro: z.string().optional(),
26
- }),
27
- auth: { policies: [{ action: 'plugin::content-manager.explorer.update' }] },
28
- createHandler: (strapi: any) => async ({ args }: any) => {
29
- const r = await createContentTools(strapi).traduzir(args);
30
- return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
31
- },
32
- });
5
+ export default defineTool({
6
+ name: 'mcp_chat_traduzir',
7
+ title: 'Translate localized content',
8
+ description:
9
+ 'Translate localized content into one or more languages. Creates missing locales, translates field by field (long text is split and reassembled, never overflows) and publishes (only on content-types with Draft & Publish). Without uid/documentId, translates ALL localized content-types. Handles many locales at once.',
10
+ resolveInputSchema: () =>
11
+ z.object({
12
+ target_locales: z.array(z.string()).min(1),
13
+ source_locale: z.string().optional(),
14
+ uid: z.string().optional(),
15
+ documentId: z.string().optional(),
16
+ publish: z.boolean().optional(),
17
+ }),
18
+ resolveOutputSchema: () =>
19
+ z.object({
20
+ ok: z.boolean().optional(),
21
+ source: z.string().optional(),
22
+ por_locale: z.array(z.any()).optional(),
23
+ erro: z.string().optional(),
24
+ }),
25
+ auth: { policies: [{ action: 'plugin::content-manager.explorer.update' }] },
26
+ createHandler: (strapi: any) => async ({ args }) => {
27
+ const r = await createContentTools(strapi).traduzir(args);
28
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
33
29
  },
34
- };
35
-
36
- export default tool;
30
+ });
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Tipos para os módulos de tool do MCP (padrão inspirado no exemplo do Paul
3
- * Bratslavsky: github.com/PaulBratslavsky/strapi-mcp-demo-and-tool-extension).
4
- * Cada tool é um módulo com um `register(registerTool, strapi)`.
2
+ * Tipos do MCP do plugin. As tools agora são DEFINIÇÕES puras criadas com
3
+ * `defineTool` (ver ./define.ts) e registradas a partir de um array — alinhado
4
+ * à direção do PR #26603 (`ai.mcp.defineTool`). Re-exportamos os tipos de
5
+ * `./define` aqui por conveniência/compatibilidade.
5
6
  */
6
-
7
- export type RegisterTool = (toolDef: Record<string, any>) => void;
8
-
9
- export type StrapiMcpToolModule = {
10
- register: (registerTool: RegisterTool, strapi: any) => void;
11
- };
7
+ export type {
8
+ McpToolDef,
9
+ McpResourceDef,
10
+ McpPromptDef,
11
+ McpToolResult,
12
+ McpAuth,
13
+ RegisterTool,
14
+ } from './define';
@@ -14,6 +14,18 @@ const baseHeaders = {
14
14
  Accept: 'application/json, text/event-stream',
15
15
  };
16
16
 
17
+ /** fetch com timeout (AbortController) — nenhum endpoint MCP pode pendurar uma
18
+ * request do Strapi indefinidamente. */
19
+ const fetchT = async (url: string, opts: any, timeoutMs = 8000): Promise<Response> => {
20
+ const ctrl = new AbortController();
21
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
22
+ try {
23
+ return await fetch(url, { ...opts, signal: ctrl.signal });
24
+ } finally {
25
+ clearTimeout(t);
26
+ }
27
+ };
28
+
17
29
  const parseSse = (text: string): any => {
18
30
  const dataLines = text
19
31
  .split('\n')
@@ -50,7 +62,7 @@ export class McpClient {
50
62
  }
51
63
 
52
64
  async init(): Promise<void> {
53
- const res = await fetch(this.url, {
65
+ const res = await fetchT(this.url, {
54
66
  method: 'POST',
55
67
  headers: this.headers(),
56
68
  body: JSON.stringify({
@@ -67,7 +79,7 @@ export class McpClient {
67
79
  this.sessionId = res.headers.get('mcp-session-id') || undefined;
68
80
  await res.text();
69
81
  // Notifica que o handshake terminou (sem corpo de resposta relevante).
70
- await fetch(this.url, {
82
+ await fetchT(this.url, {
71
83
  method: 'POST',
72
84
  headers: this.headers(),
73
85
  body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
@@ -75,7 +87,7 @@ export class McpClient {
75
87
  }
76
88
 
77
89
  private async rpc(method: string, params: any, id: number): Promise<any> {
78
- const res = await fetch(this.url, {
90
+ const res = await fetchT(this.url, {
79
91
  method: 'POST',
80
92
  headers: this.headers(),
81
93
  body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
@@ -80,32 +80,53 @@ interface CollectResult {
80
80
  tree: string[];
81
81
  }
82
82
 
83
- /** Coleta os arquivos de código mais promissores (com conteúdo) + árvore. */
83
+ /** Detecta um array de objetos declarado inline (ex.: `const services = [{...}]`),
84
+ * inclusive DENTRO de componentes — é onde os frontends Lovable/Figma guardam
85
+ * os dados (serviços, avaliações, etc.). */
86
+ function hasInlineDataArray(content: string): boolean {
87
+ return /(?:export\s+)?const\s+\w+\s*(?::[^=\n]+)?=\s*\[\s*\{/.test(content);
88
+ }
89
+
90
+ /**
91
+ * Coleta os arquivos de código mais promissores (com conteúdo) + árvore.
92
+ *
93
+ * Ciente de conteúdo: além da pontuação por caminho, dá um bônus forte a QUALQUER
94
+ * arquivo que contenha um array de objetos inline — assim componentes (.tsx/.jsx)
95
+ * com `const X = [{...}]` entram na análise (antes eram excluídos por serem
96
+ * componentes, e os dados embutidos neles nunca eram modelados).
97
+ */
84
98
  function collectFiles(frontendDir: string): CollectResult {
85
99
  const all: string[] = [];
86
100
  walk(frontendDir, frontendDir, all);
87
-
88
101
  const tree = all.slice().sort();
89
- // candidatos com conteúdo de array/objeto exportado, por pontuação
90
- const ranked = all
91
- .map((rel) => ({ rel, s: score(rel) }))
92
- .filter((x) => x.s > 0)
93
- .sort((a, b) => b.s - a.s);
94
102
 
95
- const files: { rel: string; content: string }[] = [];
96
- let total = 0;
97
- for (const { rel } of ranked) {
98
- if (files.length >= MAX_FILES || total >= MAX_TOTAL_CHARS) break;
103
+ // e pontua cada candidato (pontuação por caminho + bônus por dados inline).
104
+ const scored: { rel: string; content: string; s: number }[] = [];
105
+ for (const rel of all) {
106
+ let content: string;
99
107
  try {
100
- let content = fs.readFileSync(path.join(frontendDir, rel), 'utf8');
101
- // só interessa se houver dados estruturados ou tipos
102
- if (!/export\s+(const|default|type|interface)/.test(content)) continue;
103
- if (content.length > MAX_FILE_CHARS) content = content.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */';
104
- files.push({ rel, content });
105
- total += content.length;
108
+ content = fs.readFileSync(path.join(frontendDir, rel), 'utf8');
106
109
  } catch {
107
- /* ignore */
110
+ continue;
108
111
  }
112
+ const dataArray = hasInlineDataArray(content);
113
+ const hasExport = /export\s+(const|default|type|interface)/.test(content);
114
+ if (!hasExport && !dataArray) continue; // sem dados nem exports → ignora
115
+ let s = score(rel);
116
+ if (dataArray) s += 8; // arrays de objetos inline valem muito (incl. componentes)
117
+ if (s <= 0) continue;
118
+ scored.push({ rel, content, s });
119
+ }
120
+ scored.sort((a, b) => b.s - a.s);
121
+
122
+ const files: { rel: string; content: string }[] = [];
123
+ let total = 0;
124
+ for (const it of scored) {
125
+ if (files.length >= MAX_FILES || total >= MAX_TOTAL_CHARS) break;
126
+ let content = it.content;
127
+ if (content.length > MAX_FILE_CHARS) content = content.slice(0, MAX_FILE_CHARS) + '\n/* …truncado… */';
128
+ files.push({ rel: it.rel, content });
129
+ total += content.length;
109
130
  }
110
131
  return { files, tree };
111
132
  }
@@ -341,13 +362,15 @@ Gere um JSON "strapi.manifest.json" com ESTE formato:
341
362
 
342
363
  REGRAS:
343
364
  - Crie uma content-type para cada COLEÇÃO de dados (arrays de objetos). Use os MESMOS nomes de campo do código.
365
+ - IMPORTANTE: muitos frontends guardam os dados em arrays declarados INLINE dentro de componentes (.tsx/.jsx), ex.: \`const services = [{ name, price, desc }]\`, \`const reviews = [...]\`, \`const hours = [...]\`. TRATE esses arrays como coleções e modele cada um como um collectionType, mesmo que estejam dentro de um componente de UI.
366
+ - Ao modelar um array desses, inclua SOMENTE os campos que são dados/texto (ex.: name, price, desc, label, href, value). IGNORE props que são código/apresentação: componentes de ícone (ex.: \`icon: Scissors\`), elementos React, funções, classes CSS, imports de imagem.
344
367
  - Dados de "configuração do site" (objeto único: nome, telefone, etc.) → singleType.
345
368
  - Campos string longos/descrições → "text" ou "richtext". Listas de strings → "json".
346
369
  - Use "date"/"datetime" SOMENTE para datas ISO completas (YYYY-MM-DD). Datas parciais como "2025-04" ou textos livres → use "string" (senão o seed falha).
347
370
  - Imagens (imports de assets ou caminhos) → "media" (NÃO coloque o valor da imagem no seed; omita o campo no seed).
348
- - Em "seed", extraia o conteúdo REAL hardcoded no código, omitindo campos de mídia e relações.
371
+ - Em "seed", copie o conteúdo REAL hardcoded no código, VERBATIM (exatamente como está, sem reescrever, traduzir ou inventar), omitindo campos de mídia e relações. Todo valor de seed TEM que existir literalmente no código fornecido.
349
372
  - Foque APENAS em coleções/objetos de dados — NÃO precisa modelar textos soltos de UI (isso é tratado à parte).
350
- - NÃO invente. singularName kebab-case, sem repetir. Relações só apontam para types definidos por você.
373
+ - NÃO invente NADA. Se não tiver certeza de um valor, omita-o. singularName kebab-case, sem repetir. Relações só apontam para types definidos por você.
351
374
  - Se não houver coleções de dados, devolva contentTypes: [] e seed: [].
352
375
  - Responda APENAS com o JSON, nada de markdown.
353
376
 
@@ -465,6 +488,55 @@ export async function inferManifest(
465
488
  result.warnings.push('Sem OPENAI_API_KEY: modelando os TEXTOS (determinístico); coleções de dados não inferidas.');
466
489
  }
467
490
 
491
+ // 3b) TRAVA ANTI-ALUCINAÇÃO (determinística): todo valor de seed gerado pela IA
492
+ // TEM que existir literalmente no código analisado. Construímos um "haystack"
493
+ // com o código-fonte real (não truncado) e descartamos entradas cujo conteúdo
494
+ // não aparece nele. Assim a IA não consegue inventar dados — só transcrever.
495
+ if (dataCts.length && dataSeed.length) {
496
+ const parts: string[] = [];
497
+ for (const f of collected.files) {
498
+ try {
499
+ parts.push(fs.readFileSync(path.join(frontendDir, f.rel), 'utf8'));
500
+ } catch {
501
+ parts.push(f.content);
502
+ }
503
+ }
504
+ const norm = (x: any) => String(x).toLowerCase().replace(/[^a-z0-9]+/g, '');
505
+ const hay = norm(parts.join('\n'));
506
+ const present = (v: any) => {
507
+ if (typeof v !== 'string') return false;
508
+ const n = norm(v);
509
+ return n.length >= 4 && hay.includes(n); // valores curtos não são distintivos
510
+ };
511
+
512
+ const keep = new Set<string>();
513
+ const verifiedSeed: any[] = [];
514
+ let droppedEntries = 0;
515
+ for (const grp of dataSeed) {
516
+ const entries = (grp.entries ?? []).filter((e: any) => {
517
+ const ok = Object.values(e).some(present);
518
+ if (!ok) droppedEntries++;
519
+ return ok;
520
+ });
521
+ if (entries.length) {
522
+ verifiedSeed.push({ ...grp, entries });
523
+ keep.add(grp.singularName);
524
+ }
525
+ }
526
+ // coleções de dados sem NENHUMA entrada verificada são provável alucinação → fora.
527
+ const verifiedCts = dataCts.filter(
528
+ (ct: any) => ct.kind === 'singleType' || keep.has(ct.singularName)
529
+ );
530
+ const droppedCts = dataCts.length - verifiedCts.length;
531
+ if (droppedEntries || droppedCts) {
532
+ result.warnings.push(
533
+ `Anti-alucinação: descartei ${droppedEntries} entrada(s) e ${droppedCts} content-type(s) cujos valores não batiam com o código.`
534
+ );
535
+ }
536
+ dataCts = verifiedCts;
537
+ dataSeed = verifiedSeed;
538
+ }
539
+
468
540
  // 4) TEXTOS via extração DETERMINÍSTICA (garantido, sem IA, escala em qualquer tamanho)
469
541
  const budget = 60 - dataCts.length - 1; // teto de content-types do manifest
470
542
  const page = buildPageContentTypes(pageTexts, budget);
@@ -4,6 +4,7 @@ import type { Manifest } from './manifest';
4
4
  import { adapterForManifest, type LinkContext } from './adapters';
5
5
  import { generateTypes } from './types-gen';
6
6
  import { apiUid } from './generate';
7
+ import { FRONTEND_BASE_PORT } from './runner';
7
8
 
8
9
  /**
9
10
  * Link: conecta o frontend já instalado à Strapi.
@@ -31,7 +32,11 @@ export interface LinkResult {
31
32
  envPreserved: string[];
32
33
  typesFile: string;
33
34
  previewFile: string;
34
- previewAction: 'created' | 'sidecar' | 'skipped';
35
+ previewAction: 'created' | 'merged' | 'skipped';
36
+ /** vars adicionadas ao .env do BACKEND (CLIENT_URL/PREVIEW_SECRET). */
37
+ backendEnvAdded: string[];
38
+ /** ação no config/middlewares (CSP frame-src para o iframe do preview). */
39
+ cspAction: 'patched' | 'already' | 'manual' | 'skipped';
35
40
  errors: string[];
36
41
  }
37
42
 
@@ -79,44 +84,68 @@ function mergeEnv(
79
84
  // config de Preview da Strapi
80
85
  // ---------------------------------------------------------------------------
81
86
 
87
+ // marca que o config/admin.ts já tem o preview do mcp-chat mesclado (idempotência).
88
+ const PREVIEW_MARKER = 'mcp-chat:preview-merged';
89
+ const PREVIEW_MODULE = 'mcp-chat-preview';
90
+
82
91
  /**
83
- * Gera o conteúdo de config/admin.ts com o Preview configurado: mapeia cada uid
84
- * para a rota de preview declarada no manifest e monta a URL com o slug do doc.
92
+ * Gera o MÓDULO de preview (config/mcp-chat-preview.ts): default-exporta o
93
+ * fragmento { preview: {...} } do config/admin, com o handler que mapeia uid ->
94
+ * rota do frontend. É 100% owned pelo gerador (sempre reescrito).
95
+ *
96
+ * Decisões importantes:
97
+ * - Rota DEFAULT "/" para qualquer type sem rota declarada — assim o botão
98
+ * Preview SEMPRE tem URL (singleTypes de uma landing page caem aqui).
99
+ * - URL por framework: SPA (Vite/TanStack) abre a página direta com query;
100
+ * Next usa a convenção /api/preview (draft mode).
85
101
  */
86
- export function buildPreviewConfig(manifest: Manifest): string {
102
+ export function buildPreviewConfig(
103
+ manifest: Manifest,
104
+ framework: string = manifest.framework
105
+ ): string {
87
106
  const routes: Record<string, string> = {};
88
107
  for (const ct of manifest.contentTypes) {
89
- if (ct.preview?.route) routes[apiUid(ct.singularName)] = ct.preview.route;
108
+ // rota declarada quando há página de detalhe; senão a raiz da app.
109
+ routes[apiUid(ct.singularName)] = ct.preview?.route ?? '/';
90
110
  }
91
111
  const routesJson = JSON.stringify(routes, null, 2);
112
+ const isNext = framework === 'next';
113
+
114
+ // ramo de montagem da URL final, por framework.
115
+ const urlBranch = isNext
116
+ ? ` // Next.js: rota de draft mode que seta o cookie e redireciona p/ \`path\`.
117
+ const qs = new URLSearchParams({ secret, status: status ?? 'draft', path: pathname });
118
+ return \`\${clientUrl}/api/preview?\${qs.toString()}\`;`
119
+ : ` // SPA (Vite/TanStack): abre a página direta; o front lê ?preview/status.
120
+ const qs = new URLSearchParams({ preview: '1', status: status ?? 'draft', secret });
121
+ return \`\${clientUrl}\${pathname}?\${qs.toString()}\`;`;
92
122
 
93
- return `// Preview gerado pelo mcp-chat a partir do strapi.manifest.json.
123
+ return `// Preview gerado pelo mcp-chat a partir do strapi.manifest.json (framework: ${framework}).
94
124
  // Mapa uid -> rota do frontend (placeholders :campo são preenchidos pelo doc).
125
+ // Este arquivo é mesclado em config/admin.ts — não precisa editá-lo à mão.
95
126
  const PREVIEW_ROUTES: Record<string, string> = ${routesJson};
96
127
 
97
- export default ({ env }) => ({
98
- auth: {
99
- secret: env('ADMIN_JWT_SECRET'),
100
- },
101
- apiToken: { salt: env('API_TOKEN_SALT') },
102
- transfer: { token: { salt: env('TRANSFER_TOKEN_SALT') } },
128
+ export default ({ env }: { env: any }) => ({
103
129
  preview: {
104
130
  enabled: true,
105
131
  config: {
106
132
  allowedOrigins: [env('CLIENT_URL', 'http://localhost:3000')],
107
133
  async handler(uid: string, { documentId, locale, status }: any) {
108
- const route = PREVIEW_ROUTES[uid];
109
- if (!route) return null;
110
- const doc = await strapi.documents(uid as any).findOne({ documentId, locale });
111
- if (!doc) return null;
112
- // substitui :campo pelos valores do documento (ex.: :slug)
113
- const pathname = route.replace(/:([a-zA-Z0-9_]+)/g, (_m, f) =>
114
- encodeURIComponent(String((doc as any)[f] ?? ''))
115
- );
134
+ const route = PREVIEW_ROUTES[uid] ?? '/';
116
135
  const clientUrl = env('CLIENT_URL', 'http://localhost:3000');
117
136
  const secret = env('PREVIEW_SECRET', '');
118
- const qs = new URLSearchParams({ secret, status: status ?? 'draft', path: pathname });
119
- return \`\${clientUrl}/api/preview?\${qs.toString()}\`;
137
+
138
+ // só busca o doc se a rota tiver placeholders (ex.: :slug) a preencher.
139
+ let pathname = route;
140
+ if (pathname.includes(':')) {
141
+ const doc = await strapi.documents(uid as any).findOne({ documentId, locale });
142
+ if (!doc) return null;
143
+ pathname = pathname.replace(/:([a-zA-Z0-9_]+)/g, (_m, f) =>
144
+ encodeURIComponent(String((doc as any)[f] ?? ''))
145
+ );
146
+ }
147
+
148
+ ${urlBranch}
120
149
  },
121
150
  },
122
151
  },
@@ -124,6 +153,112 @@ export default ({ env }) => ({
124
153
  `;
125
154
  }
126
155
 
156
+ /** admin.ts auto-contido (quando o projeto ainda não tem um). */
157
+ function buildStandaloneAdmin(): string {
158
+ return `// ${PREVIEW_MARKER} — admin.ts gerado pelo mcp-chat (preview incluído).
159
+ import previewConfig from './${PREVIEW_MODULE}';
160
+
161
+ export default ({ env }: { env: any }) => ({
162
+ auth: { secret: env('ADMIN_JWT_SECRET') },
163
+ apiToken: { salt: env('API_TOKEN_SALT') },
164
+ transfer: { token: { salt: env('TRANSFER_TOKEN_SALT') } },
165
+ secrets: { encryptionKey: env('ENCRYPTION_KEY') },
166
+ flags: {
167
+ nps: env.bool('FLAG_NPS', true),
168
+ promoteEE: env.bool('FLAG_PROMOTE_EE', true),
169
+ },
170
+ ...previewConfig({ env }),
171
+ });
172
+ `;
173
+ }
174
+
175
+ /**
176
+ * Wrapper que PRESERVA o admin existente (movido p/ admin.base.ts) e mescla o
177
+ * preview por cima. Como o admin é mesclado e não substituído, qualquer config
178
+ * do usuário (auth, flags, custom) continua valendo.
179
+ */
180
+ function buildAdminWrapper(): string {
181
+ return `// ${PREVIEW_MARKER} — preview do mcp-chat mesclado sobre o admin original.
182
+ // Sua config original está preservada em ./admin.base — edite lá, não aqui.
183
+ import base from './admin.base';
184
+ import previewConfig from './${PREVIEW_MODULE}';
185
+
186
+ export default (ctx: any) => {
187
+ const b = typeof base === 'function' ? (base as any)(ctx) : (base ?? {});
188
+ return { ...b, ...previewConfig(ctx) };
189
+ };
190
+ `;
191
+ }
192
+
193
+ /** Lê a porta de dev do frontend (Vite) do config; null se não achar. */
194
+ export function detectFrontendPort(frontendDir: string): number | null {
195
+ for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
196
+ try {
197
+ const c = fs.readFileSync(path.join(frontendDir, f), 'utf8');
198
+ const m = c.match(/port\s*:\s*(\d{2,5})/);
199
+ if (m) return parseInt(m[1], 10);
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // CSP do admin: liberar frame-src para o iframe do preview
209
+ // ---------------------------------------------------------------------------
210
+
211
+ const CSP_MARKER = 'mcp-chat:csp-frame';
212
+
213
+ /** Bloco strapi::security com frame-src liberado para o dev server do preview. */
214
+ function securityBlock(): string {
215
+ return ` // ${CSP_MARKER} — libera o frame-src p/ o admin embutir o preview do frontend
216
+ // (dev server local em qualquer porta). Sem isto a CSP padrão (default-src 'self')
217
+ // bloqueia o iframe e o preview fica em branco.
218
+ {
219
+ name: 'strapi::security',
220
+ config: {
221
+ contentSecurityPolicy: {
222
+ useDefaults: true,
223
+ directives: {
224
+ 'connect-src': ["'self'", 'https:', 'http:'],
225
+ 'frame-src': ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'],
226
+ 'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io'],
227
+ 'media-src': ["'self'", 'data:', 'blob:'],
228
+ upgradeInsecureRequests: null,
229
+ },
230
+ },
231
+ },
232
+ },`;
233
+ }
234
+
235
+ /**
236
+ * Garante que o config/middlewares tenha frame-src liberado p/ o preview.
237
+ * Não-destrutivo e idempotente:
238
+ * - já tem o marcador → 'already';
239
+ * - tem o `'strapi::security'` string padrão → troca pelo bloco com frame-src → 'patched';
240
+ * - já é um objeto custom de security → 'manual' (não mexe; reporta p/ o usuário ajustar).
241
+ */
242
+ function patchSecurityMiddleware(strapiAppDir: string, dryRun?: boolean): LinkResult['cspAction'] {
243
+ const configDir = path.join(strapiAppDir, 'config');
244
+ const file = ['middlewares.ts', 'middlewares.js'].find((f) =>
245
+ fs.existsSync(path.join(configDir, f))
246
+ );
247
+ if (!file) return 'skipped';
248
+ const p = path.join(configDir, file);
249
+ const content = fs.readFileSync(p, 'utf8');
250
+
251
+ if (content.includes(CSP_MARKER) || content.includes("'frame-src'")) return 'already';
252
+
253
+ // troca a entrada string padrão pelo bloco objeto (cobre o starter do Strapi).
254
+ // O bloco já vem com indentação de 2 espaços, igual ao array do starter.
255
+ const m = content.match(/^[ \t]*['"]strapi::security['"]\s*,/m);
256
+ if (!m) return 'manual'; // já customizado de outra forma → não arrisca
257
+ const next = content.replace(m[0], securityBlock());
258
+ if (!dryRun) fs.writeFileSync(p, next, 'utf8');
259
+ return 'patched';
260
+ }
261
+
127
262
  // ---------------------------------------------------------------------------
128
263
  // orquestração do link
129
264
  // ---------------------------------------------------------------------------
@@ -146,6 +281,8 @@ export function linkFrontend(
146
281
  typesFile: 'strapi-types.ts',
147
282
  previewFile: 'config/admin.ts',
148
283
  previewAction: 'skipped',
284
+ backendEnvAdded: [],
285
+ cspAction: 'skipped',
149
286
  errors: [],
150
287
  };
151
288
 
@@ -177,27 +314,87 @@ export function linkFrontend(
177
314
  result.errors.push(`types: ${e?.message ?? e}`);
178
315
  }
179
316
 
180
- // 3) config de Preview (não-destrutivo)
317
+ // 3) config de Preview — merge REAL no config/admin.ts (não sidecar morto).
181
318
  try {
182
- const adminPath = path.join(opts.strapiAppDir, 'config', 'admin.ts');
183
- const content = buildPreviewConfig(manifest);
184
- if (fs.existsSync(adminPath)) {
185
- // não sobrescreve config existente: escreve sidecar e reporta
186
- result.previewFile = 'config/admin.mcp-chat-preview.ts';
187
- const sidecar = path.join(opts.strapiAppDir, 'config', 'admin.mcp-chat-preview.ts');
188
- if (!opts.dryRun) fs.writeFileSync(sidecar, content, 'utf8');
189
- result.previewAction = 'sidecar';
190
- } else {
191
- if (!opts.dryRun) {
192
- fs.mkdirSync(path.dirname(adminPath), { recursive: true });
193
- fs.writeFileSync(adminPath, content, 'utf8');
319
+ const configDir = path.join(opts.strapiAppDir, 'config');
320
+ if (!ensureInside(opts.strapiAppDir, configDir)) throw new Error('config fora do strapiAppDir');
321
+
322
+ const adminPath = path.join(configDir, 'admin.ts');
323
+ const adminBasePath = path.join(configDir, 'admin.base.ts');
324
+ const modulePath = path.join(configDir, `${PREVIEW_MODULE}.ts`);
325
+
326
+ if (!opts.dryRun) {
327
+ fs.mkdirSync(configDir, { recursive: true });
328
+ // 3a) módulo de preview — sempre (re)escrito (owned pelo gerador).
329
+ fs.writeFileSync(modulePath, buildPreviewConfig(manifest, adapter.framework), 'utf8');
330
+ // limpa o sidecar morto de versões antigas, se existir.
331
+ try {
332
+ fs.unlinkSync(path.join(configDir, 'admin.mcp-chat-preview.ts'));
333
+ } catch {
334
+ /* não existia */
194
335
  }
336
+ }
337
+ result.previewFile = `config/${PREVIEW_MODULE}.ts`;
338
+
339
+ if (!fs.existsSync(adminPath)) {
340
+ // 3b) sem admin.ts: cria um auto-contido já com o preview.
341
+ if (!opts.dryRun) fs.writeFileSync(adminPath, buildStandaloneAdmin(), 'utf8');
195
342
  result.previewAction = 'created';
343
+ } else {
344
+ const adminContent = fs.readFileSync(adminPath, 'utf8');
345
+ if (!adminContent.includes(PREVIEW_MARKER)) {
346
+ // 3c) admin.ts do usuário: preserva em admin.base.ts e troca por wrapper.
347
+ if (!opts.dryRun) {
348
+ if (!fs.existsSync(adminBasePath)) {
349
+ fs.writeFileSync(adminBasePath, adminContent, 'utf8');
350
+ }
351
+ fs.writeFileSync(adminPath, buildAdminWrapper(), 'utf8');
352
+ }
353
+ }
354
+ // se já tinha o marcador, só o módulo (3a) foi atualizado — idempotente.
355
+ result.previewAction = 'merged';
196
356
  }
197
357
  } catch (e: any) {
198
358
  result.errors.push(`preview: ${e?.message ?? e}`);
199
359
  }
200
360
 
361
+ // 4) .env do BACKEND: CLIENT_URL (origem do iframe + allowedOrigins) e, se houver,
362
+ // PREVIEW_SECRET. Aditivo: nunca sobrescreve o que o usuário já definiu.
363
+ //
364
+ // CRÍTICO: o preview NATIVO do Strapi usa CLIENT_URL, e ele PRECISA bater com a
365
+ // porta onde o runner sobe o dev server. O runner ignora o vite.config e usa
366
+ // FRONTEND_BASE_PORT — então CLIENT_URL tem que apontar pra ESSA porta, não pra
367
+ // porta do vite.config (senão o preview nativo abre uma porta morta).
368
+ try {
369
+ const clientUrl =
370
+ opts.context.frontendUrl || `http://localhost:${FRONTEND_BASE_PORT}`;
371
+ const backendVars: Record<string, string> = { CLIENT_URL: clientUrl };
372
+ if (opts.context.previewSecret) backendVars.PREVIEW_SECRET = opts.context.previewSecret;
373
+
374
+ const backendEnvPath = path.join(opts.strapiAppDir, '.env');
375
+ if (ensureInside(opts.strapiAppDir, backendEnvPath)) {
376
+ const existing = fs.existsSync(backendEnvPath) ? fs.readFileSync(backendEnvPath, 'utf8') : '';
377
+ const { content, added } = mergeEnv(existing, backendVars);
378
+ result.backendEnvAdded = added;
379
+ if (!opts.dryRun && added.length) fs.writeFileSync(backendEnvPath, content, 'utf8');
380
+ }
381
+ } catch (e: any) {
382
+ result.errors.push(`backend env: ${e?.message ?? e}`);
383
+ }
384
+
385
+ // 5) CSP do admin: libera frame-src p/ o iframe do preview (senão fica em branco).
386
+ try {
387
+ result.cspAction = patchSecurityMiddleware(opts.strapiAppDir, opts.dryRun);
388
+ if (result.cspAction === 'manual') {
389
+ result.errors.push(
390
+ 'CSP: config/middlewares já tem strapi::security customizado — adicione manualmente ' +
391
+ "frame-src 'self' http://localhost:* http://127.0.0.1:* para o preview embutir o frontend."
392
+ );
393
+ }
394
+ } catch (e: any) {
395
+ result.errors.push(`csp: ${e?.message ?? e}`);
396
+ }
397
+
201
398
  result.ok = result.errors.length === 0;
202
399
  return result;
203
400
  }
@@ -6,8 +6,9 @@ import { writeApis, requestReload, type WriteResult } from './write';
6
6
  export { requestReload };
7
7
  import { seedContent, type SeedResult } from './seed';
8
8
  import { linkFrontend, type LinkResult } from './link';
9
+ import { FRONTEND_BASE_PORT } from './runner';
9
10
  import { grantPublicRead, type PermissionsResult } from './permissions';
10
- import { adapterForManifest, type LinkContext } from './adapters';
11
+ import { type LinkContext } from './adapters';
11
12
  import { apiUid } from './generate';
12
13
 
13
14
  /**
@@ -206,9 +207,9 @@ export async function runPendingProvision(
206
207
 
207
208
  // grava o resumo de conclusão para a UI anunciar "preview pronto".
208
209
  try {
209
- const adapter = adapterForManifest(marker.manifest);
210
+ // mesma porta do runner (onde o dev server realmente sobe), p/ o preview bater.
210
211
  const previewUrl =
211
- marker.context.frontendUrl || `http://localhost:${adapter.defaultPort}`;
212
+ marker.context.frontendUrl || `http://localhost:${FRONTEND_BASE_PORT}`;
212
213
  const done: ProvisionDone = {
213
214
  name: marker.manifest.name,
214
215
  framework: marker.manifest.framework,