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.
- package/README.md +31 -5
- package/admin/src/components/AdminOverlays.tsx +8 -18
- package/admin/src/components/ErrorBoundary.tsx +29 -0
- package/admin/src/components/FloatingChat.tsx +78 -3
- package/admin/src/index.tsx +39 -5
- package/dist/server/index.js +734 -397
- package/package.json +1 -1
- package/server/src/content-tools.ts +20 -6
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/controllers/frontend.ts +7 -2
- package/server/src/index.ts +14 -2
- package/server/src/mcp/define.ts +72 -0
- package/server/src/mcp/index.ts +19 -6
- package/server/src/mcp/tools/buscar-texto.ts +18 -24
- package/server/src/mcp/tools/criar-locale.ts +20 -26
- package/server/src/mcp/tools/editar-campo.ts +29 -35
- package/server/src/mcp/tools/habilitar-i18n.ts +23 -29
- package/server/src/mcp/tools/listar-locales.ts +17 -23
- package/server/src/mcp/tools/publicar.ts +21 -27
- package/server/src/mcp/tools/traduzir.ts +26 -32
- package/server/src/mcp/types.ts +12 -9
- package/server/src/mcp-client.ts +15 -3
- package/server/src/provision/infer.ts +92 -20
- package/server/src/provision/link.ts +232 -35
- package/server/src/provision/orchestrate.ts +4 -3
- package/server/src/provision/runner.ts +44 -1
- package/server/src/provision/write.ts +92 -0
- package/server/src/services/chat.ts +36 -8
|
@@ -32,6 +32,44 @@ export interface RunInfo {
|
|
|
32
32
|
let info: RunInfo = { state: 'idle', dir: null, url: null, pm: null, error: null, log: [] };
|
|
33
33
|
let child: ChildProcess | null = null;
|
|
34
34
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
let appRootForPid: string | null = null;
|
|
36
|
+
|
|
37
|
+
// ── pidfile: rastreia o PID do dev server p/ matá-lo no shutdown e limpar
|
|
38
|
+
// órfãos no boot (ex.: após um crash/SIGKILL onde o destroy não rodou). ──
|
|
39
|
+
function pidFilePath(appRoot: string): string {
|
|
40
|
+
return path.join(appRoot, '.mcp-chat', 'frontend.pid');
|
|
41
|
+
}
|
|
42
|
+
function writePid(pid: number): void {
|
|
43
|
+
try {
|
|
44
|
+
if (!appRootForPid) return;
|
|
45
|
+
const pf = pidFilePath(appRootForPid);
|
|
46
|
+
fs.mkdirSync(path.dirname(pf), { recursive: true });
|
|
47
|
+
fs.writeFileSync(pf, String(pid), 'utf8');
|
|
48
|
+
} catch { /* best-effort */ }
|
|
49
|
+
}
|
|
50
|
+
function clearPid(): void {
|
|
51
|
+
try {
|
|
52
|
+
if (appRootForPid) fs.unlinkSync(pidFilePath(appRootForPid));
|
|
53
|
+
} catch { /* não existia */ }
|
|
54
|
+
}
|
|
55
|
+
function isAlive(pid: number): boolean {
|
|
56
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Mata um dev server órfão deixado por uma execução anterior (crash/SIGKILL não
|
|
60
|
+
* roda o destroy()). Chamado no bootstrap do plugin — garante "limpar ao
|
|
61
|
+
* desligar" mesmo quando o shutdown não foi limpo, e evita o EADDRINUSE.
|
|
62
|
+
*/
|
|
63
|
+
export function cleanupStaleFrontend(appRoot: string): void {
|
|
64
|
+
appRootForPid = appRoot;
|
|
65
|
+
try {
|
|
66
|
+
const pf = pidFilePath(appRoot);
|
|
67
|
+
if (!fs.existsSync(pf)) return;
|
|
68
|
+
const pid = parseInt(fs.readFileSync(pf, 'utf8').trim(), 10);
|
|
69
|
+
if (pid && isAlive(pid)) { try { process.kill(pid, 'SIGTERM'); } catch { /* já morto */ } }
|
|
70
|
+
fs.unlinkSync(pf);
|
|
71
|
+
} catch { /* best-effort */ }
|
|
72
|
+
}
|
|
35
73
|
|
|
36
74
|
function detectPM(dir: string): string {
|
|
37
75
|
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
@@ -55,7 +93,7 @@ function detectFramework(dir: string): 'vite' | 'next' | 'other' {
|
|
|
55
93
|
* - 3000 (Next default) e 1337 (Strapi).
|
|
56
94
|
* Evita colisão com o admin do Strapi (que responde 426 a requests não-WS).
|
|
57
95
|
*/
|
|
58
|
-
const FRONTEND_BASE_PORT = 4321;
|
|
96
|
+
export const FRONTEND_BASE_PORT = 4321;
|
|
59
97
|
|
|
60
98
|
/** Acha uma porta TCP livre a partir de `start`, testando em 0.0.0.0 (pega
|
|
61
99
|
* ocupações em `*:porta` de qualquer interface IPv4). */
|
|
@@ -101,11 +139,14 @@ export function stopFrontend(): void {
|
|
|
101
139
|
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
102
140
|
child = null;
|
|
103
141
|
}
|
|
142
|
+
clearPid(); // limpa ao desligar — não deixa órfão
|
|
104
143
|
if (info.state !== 'error') info.state = 'idle';
|
|
105
144
|
}
|
|
106
145
|
|
|
107
146
|
export async function startFrontend(_strapi: any, opts: { dir: string; url: string }): Promise<RunInfo> {
|
|
108
147
|
const { dir } = opts;
|
|
148
|
+
// raiz da app p/ o pidfile (limpeza de órfãos no boot / shutdown).
|
|
149
|
+
if (_strapi?.dirs?.app?.root) appRootForPid = _strapi.dirs.app.root;
|
|
109
150
|
|
|
110
151
|
// idempotente: já rodando/subindo para o mesmo dir
|
|
111
152
|
if (child && info.dir === dir && ['installing', 'starting', 'running'].includes(info.state)) {
|
|
@@ -138,12 +179,14 @@ export async function startFrontend(_strapi: any, opts: { dir: string; url: stri
|
|
|
138
179
|
const startDev = () => {
|
|
139
180
|
info.state = 'starting';
|
|
140
181
|
child = spawnIn(pm, devArgs);
|
|
182
|
+
if (child.pid) writePid(child.pid); // rastreia p/ limpar no shutdown/boot
|
|
141
183
|
child.stdout?.on('data', (d) => pushLog(d));
|
|
142
184
|
child.stderr?.on('data', (d) => pushLog(d));
|
|
143
185
|
child.on('exit', (code) => {
|
|
144
186
|
// processo morreu → frontend DOWN. Zera o child e libera o estado p/ que
|
|
145
187
|
// apertar Preview de novo reinicie (em vez de ficar preso em "running").
|
|
146
188
|
child = null;
|
|
189
|
+
clearPid();
|
|
147
190
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
148
191
|
if (info.state === 'running') info.state = 'idle';
|
|
149
192
|
else { info.state = 'error'; info.error = `dev encerrou (código ${code}). Veja o log.`; }
|
|
@@ -49,6 +49,58 @@ function isDev(): boolean {
|
|
|
49
49
|
return process.env.NODE_ENV === 'development';
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Tipos de atributo que a Strapi 5 aceita num schema.json. Um type fora desta
|
|
53
|
+
// lista faz a Strapi recusar o boot — então validamos ANTES de escrever.
|
|
54
|
+
const KNOWN_ATTR_TYPES = new Set([
|
|
55
|
+
'string', 'text', 'richtext', 'blocks', 'email', 'password', 'uid', 'enumeration',
|
|
56
|
+
'json', 'integer', 'biginteger', 'decimal', 'float', 'date', 'time', 'datetime',
|
|
57
|
+
'timestamp', 'boolean', 'media', 'relation', 'component', 'dynamiczone',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Valida o conteúdo de um schema.json gerado contra o formato que a Strapi 5
|
|
62
|
+
* exige. Retorna a lista de erros (vazia = ok). É a trava que garante que uma
|
|
63
|
+
* provisão NUNCA escreva um schema que impeça o Strapi de bootar.
|
|
64
|
+
*/
|
|
65
|
+
function validateApi(api: GeneratedApi): string[] {
|
|
66
|
+
const errs: string[] = [];
|
|
67
|
+
const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
|
|
68
|
+
if (!rel) {
|
|
69
|
+
errs.push(`${api.singularName}: schema.json ausente nos arquivos gerados`);
|
|
70
|
+
return errs;
|
|
71
|
+
}
|
|
72
|
+
let schema: any;
|
|
73
|
+
try {
|
|
74
|
+
schema = JSON.parse(api.files[rel]);
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
errs.push(`${api.singularName}: schema.json não é JSON válido (${e?.message ?? e})`);
|
|
77
|
+
return errs;
|
|
78
|
+
}
|
|
79
|
+
if (schema?.kind !== 'collectionType' && schema?.kind !== 'singleType') {
|
|
80
|
+
errs.push(`${api.singularName}: "kind" inválido (${schema?.kind})`);
|
|
81
|
+
}
|
|
82
|
+
if (!schema?.info?.singularName || !schema?.info?.pluralName) {
|
|
83
|
+
errs.push(`${api.singularName}: info.singularName/pluralName obrigatórios`);
|
|
84
|
+
}
|
|
85
|
+
const attrs = schema?.attributes;
|
|
86
|
+
if (!attrs || typeof attrs !== 'object') {
|
|
87
|
+
errs.push(`${api.singularName}: "attributes" ausente ou inválido`);
|
|
88
|
+
} else {
|
|
89
|
+
for (const [name, a] of Object.entries(attrs) as any[]) {
|
|
90
|
+
if (!a || typeof a !== 'object' || !a.type) {
|
|
91
|
+
errs.push(`${api.singularName}.${name}: atributo sem "type"`);
|
|
92
|
+
} else if (!KNOWN_ATTR_TYPES.has(a.type)) {
|
|
93
|
+
errs.push(`${api.singularName}.${name}: type desconhecido "${a.type}"`);
|
|
94
|
+
} else if (a.type === 'relation' && !a.target) {
|
|
95
|
+
errs.push(`${api.singularName}.${name}: relation sem "target"`);
|
|
96
|
+
} else if (a.type === 'component' && !a.component) {
|
|
97
|
+
errs.push(`${api.singularName}.${name}: component sem "component"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return errs;
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
/**
|
|
53
105
|
* Escreve as content-types geradas em src/api. Aditivo e idempotente: o que já
|
|
54
106
|
* existe é preservado e reportado em `skipped`.
|
|
@@ -80,6 +132,46 @@ export function writeApis(
|
|
|
80
132
|
return result;
|
|
81
133
|
}
|
|
82
134
|
|
|
135
|
+
// ── Validação ALL-OR-NOTHING (antes de tocar no disco) ──────────────────────
|
|
136
|
+
// Só serão escritas as apis que ainda não existem (trava aditiva). Validamos
|
|
137
|
+
// TODAS elas; se UMA for inválida, não escrevemos NENHUMA — assim uma provisão
|
|
138
|
+
// jamais pode deixar o Strapi com um schema quebrado e sem bootar.
|
|
139
|
+
const toWrite = apis.filter((api) => !fs.existsSync(path.join(opts.apiRoot, api.singularName)));
|
|
140
|
+
const knownSingulars = new Set<string>([
|
|
141
|
+
...apis.map((a) => a.singularName),
|
|
142
|
+
...(fs.existsSync(opts.apiRoot)
|
|
143
|
+
? fs.readdirSync(opts.apiRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
144
|
+
: []),
|
|
145
|
+
]);
|
|
146
|
+
const validationErrors: string[] = [];
|
|
147
|
+
for (const api of toWrite) {
|
|
148
|
+
validationErrors.push(...validateApi(api));
|
|
149
|
+
// relações: o target (api::<s>.<s>) precisa existir (gerado agora ou já no disco)
|
|
150
|
+
const rel = Object.keys(api.files).find((r) => r.endsWith('schema.json'));
|
|
151
|
+
if (rel) {
|
|
152
|
+
try {
|
|
153
|
+
const attrs = JSON.parse(api.files[rel])?.attributes || {};
|
|
154
|
+
for (const [name, a] of Object.entries(attrs) as any[]) {
|
|
155
|
+
if (a?.type === 'relation' && typeof a.target === 'string') {
|
|
156
|
+
const tgt = a.target.split('::')[1]?.split('.')[0];
|
|
157
|
+
if (tgt && !knownSingulars.has(tgt)) {
|
|
158
|
+
validationErrors.push(`${api.singularName}.${name}: relation aponta para "${a.target}" inexistente`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* já reportado por validateApi */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (validationErrors.length) {
|
|
168
|
+
result.errors.push(
|
|
169
|
+
'Schema gerado inválido — nada foi escrito (provisão abortada com segurança):',
|
|
170
|
+
...validationErrors
|
|
171
|
+
);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
83
175
|
for (const api of apis) {
|
|
84
176
|
const apiDir = path.join(opts.apiRoot, api.singularName);
|
|
85
177
|
|
|
@@ -25,6 +25,12 @@ type ChatInput = {
|
|
|
25
25
|
lang?: Lang;
|
|
26
26
|
/** URL da página aberta no preview — contexto do "isso aqui". */
|
|
27
27
|
previewUrl?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Status do preview atual: 'draft' (toggle de rascunho ligado) ou 'published'
|
|
30
|
+
* (live). A busca de texto segue ESTE modo — acha rascunho quando em draft, e
|
|
31
|
+
* o conteúdo no ar quando em live. Default 'draft'.
|
|
32
|
+
*/
|
|
33
|
+
previewStatus?: 'draft' | 'published';
|
|
28
34
|
/**
|
|
29
35
|
* Política de publicação. `false` (default) = modo RASCUNHO: o agente edita o
|
|
30
36
|
* draft e NÃO publica, a menos que o usuário peça explicitamente. `true` =
|
|
@@ -103,7 +109,7 @@ Be concise and actionable. ALWAYS answer in English.`,
|
|
|
103
109
|
};
|
|
104
110
|
|
|
105
111
|
export default ({ strapi }: { strapi: any }) => ({
|
|
106
|
-
async chat({ messages, image, lang = 'pt', previewUrl, autoPublish = false }: ChatInput) {
|
|
112
|
+
async chat({ messages, image, lang = 'pt', previewUrl, previewStatus = 'draft', autoPublish = false }: ChatInput) {
|
|
107
113
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
108
114
|
if (!apiKey) {
|
|
109
115
|
throw new Error(
|
|
@@ -116,7 +122,7 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
116
122
|
const { buscarTexto, editarCampo, publicar, listarLocales, criarLocale, traduzir } =
|
|
117
123
|
createContentTools(strapi);
|
|
118
124
|
const LOCAL_TOOLS: Record<string, (args: any) => Promise<any>> = {
|
|
119
|
-
buscar_texto: (a) => buscarTexto(a?.termo),
|
|
125
|
+
buscar_texto: (a) => buscarTexto(a?.termo, previewStatus),
|
|
120
126
|
editar_campo: (a) => editarCampo(a),
|
|
121
127
|
publicar: (a) => publicar(a),
|
|
122
128
|
listar_locales: () => listarLocales(),
|
|
@@ -132,7 +138,11 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
132
138
|
if (process.env.PLAYWRIGHT_MCP_URL) {
|
|
133
139
|
try {
|
|
134
140
|
const client = new McpClient(process.env.PLAYWRIGHT_MCP_URL, 'playwright');
|
|
135
|
-
|
|
141
|
+
// init com timeout: um Playwright MCP fora do ar não pode travar o chat.
|
|
142
|
+
await Promise.race([
|
|
143
|
+
client.init(),
|
|
144
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
|
|
145
|
+
]);
|
|
136
146
|
const list = await client.listTools();
|
|
137
147
|
for (const t of list) {
|
|
138
148
|
if (mcpByTool[t.name]) continue;
|
|
@@ -207,11 +217,23 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
207
217
|
});
|
|
208
218
|
|
|
209
219
|
const callOpenAI = async (body: any) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
// Timeout: uma chamada lenta da OpenAI não pode prender a request do admin.
|
|
221
|
+
const ctrl = new AbortController();
|
|
222
|
+
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
223
|
+
let res: Response;
|
|
224
|
+
try {
|
|
225
|
+
res = await fetch(OPENAI_URL, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
signal: ctrl.signal,
|
|
230
|
+
});
|
|
231
|
+
} catch (e: any) {
|
|
232
|
+
if (e?.name === 'AbortError') throw new Error('OpenAI chat: tempo limite excedido (60s).');
|
|
233
|
+
throw e;
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
}
|
|
215
237
|
if (!res.ok) throw new Error(`OpenAI chat: ${await res.text()}`);
|
|
216
238
|
return res.json() as Promise<any>;
|
|
217
239
|
};
|
|
@@ -254,6 +276,12 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
254
276
|
} catch (e: any) {
|
|
255
277
|
content = `Erro ao chamar a tool ${name}: ${e?.message || e}`;
|
|
256
278
|
}
|
|
279
|
+
// Cap defensivo: um resultado gigante não pode estourar o contexto do
|
|
280
|
+
// modelo nem inflar memória/latência.
|
|
281
|
+
const MAX_TOOL_CHARS = 12000;
|
|
282
|
+
if (content.length > MAX_TOOL_CHARS) {
|
|
283
|
+
content = content.slice(0, MAX_TOOL_CHARS) + `\n…[resultado truncado: ${content.length} chars]`;
|
|
284
|
+
}
|
|
257
285
|
convo.push({ role: 'tool', tool_call_id: call.id, content });
|
|
258
286
|
}
|
|
259
287
|
continue;
|