strapi-plugin-mcp-chat 0.3.1 → 0.5.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/ErrorBoundary.tsx +29 -0
- package/admin/src/components/FloatingChat.tsx +8 -0
- package/admin/src/index.tsx +39 -5
- package/dist/server/index.js +298 -184
- package/package.json +1 -1
- package/server/src/content-tools.ts +16 -4
- package/server/src/controllers/frontend.ts +7 -2
- package/server/src/index.ts +6 -1
- 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/write.ts +92 -0
- package/server/src/services/chat.ts +28 -6
package/server/src/mcp-client.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 }),
|
|
@@ -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
|
|
|
@@ -132,7 +132,11 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
132
132
|
if (process.env.PLAYWRIGHT_MCP_URL) {
|
|
133
133
|
try {
|
|
134
134
|
const client = new McpClient(process.env.PLAYWRIGHT_MCP_URL, 'playwright');
|
|
135
|
-
|
|
135
|
+
// init com timeout: um Playwright MCP fora do ar não pode travar o chat.
|
|
136
|
+
await Promise.race([
|
|
137
|
+
client.init(),
|
|
138
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
|
|
139
|
+
]);
|
|
136
140
|
const list = await client.listTools();
|
|
137
141
|
for (const t of list) {
|
|
138
142
|
if (mcpByTool[t.name]) continue;
|
|
@@ -207,11 +211,23 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
207
211
|
});
|
|
208
212
|
|
|
209
213
|
const callOpenAI = async (body: any) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
// Timeout: uma chamada lenta da OpenAI não pode prender a request do admin.
|
|
215
|
+
const ctrl = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
217
|
+
let res: Response;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetch(OPENAI_URL, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
signal: ctrl.signal,
|
|
224
|
+
});
|
|
225
|
+
} catch (e: any) {
|
|
226
|
+
if (e?.name === 'AbortError') throw new Error('OpenAI chat: tempo limite excedido (60s).');
|
|
227
|
+
throw e;
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
}
|
|
215
231
|
if (!res.ok) throw new Error(`OpenAI chat: ${await res.text()}`);
|
|
216
232
|
return res.json() as Promise<any>;
|
|
217
233
|
};
|
|
@@ -254,6 +270,12 @@ export default ({ strapi }: { strapi: any }) => ({
|
|
|
254
270
|
} catch (e: any) {
|
|
255
271
|
content = `Erro ao chamar a tool ${name}: ${e?.message || e}`;
|
|
256
272
|
}
|
|
273
|
+
// Cap defensivo: um resultado gigante não pode estourar o contexto do
|
|
274
|
+
// modelo nem inflar memória/latência.
|
|
275
|
+
const MAX_TOOL_CHARS = 12000;
|
|
276
|
+
if (content.length > MAX_TOOL_CHARS) {
|
|
277
|
+
content = content.slice(0, MAX_TOOL_CHARS) + `\n…[resultado truncado: ${content.length} chars]`;
|
|
278
|
+
}
|
|
257
279
|
convo.push({ role: 'tool', tool_call_id: call.id, content });
|
|
258
280
|
}
|
|
259
281
|
continue;
|