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.
@@ -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 }),
@@ -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
- await client.init();
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
- const res = await fetch(OPENAI_URL, {
211
- method: 'POST',
212
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
213
- body: JSON.stringify(body),
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;