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 CHANGED
@@ -294,11 +294,16 @@ The plugin follows the documented Strapi 5 plugin APIs:
294
294
  (`register` / `bootstrap` / `destroy` / `config` / `controllers` / `services` / `routes`).
295
295
  Routes are declared with `type: 'admin'`, so the chat/STT/TTS endpoints require an
296
296
  authenticated admin session.
297
- - **MCP** — tools are registered with `strapi.ai.mcp.registerTool` during `register()`
298
- (the documented extension point), using `z` from `@strapi/utils` for the schemas and
299
- `auth.policies` (content-manager read/update/publish) for RBAC. They're organized in a
300
- modular `server/src/mcp/` (one file per tool in `tools/`, aggregated and looped) following
301
- the structure from [Paul Bratslavsky's MCP tool-extension example](https://github.com/PaulBratslavsky/strapi-mcp-demo-and-tool-extension).
297
+ - **MCP** — tools are **defined** as pure objects with a local typed `defineTool` helper
298
+ (`server/src/mcp/define.ts`) and **registered from an array** via
299
+ `strapi.ai.mcp.registerTool` during `register()`, using `z` from `@strapi/utils` for the
300
+ schemas and `auth.policies` (content-manager read/update/publish) for RBAC. The
301
+ `defineTool`/`defineResource`/`definePrompt` identity functions infer each handler's
302
+ `args` from its input schema (no `any`) and keep the definitions side-effect-free —
303
+ mirroring the direction of Strapi's [PR #26603](https://github.com/strapi/strapi/pull/26603)
304
+ (`ai.mcp.defineTool` + the `import { ai } from "@strapi/strapi"` namespace). When that API
305
+ ships stable, migrating is a one-line import swap; until then the plugin stays on the
306
+ released `registerTool` so it runs on any Strapi ≥ 5.47 (no experimental build required).
302
307
  - **Admin** — `register()` uses only documented APIs (`app.addMenuLink`, `app.registerPlugin`).
303
308
 
304
309
  One intentional deviation: the **global floating chat** is mounted via its own React root
@@ -316,6 +321,27 @@ single spot.
316
321
  token's permissions — scope the token to only what those clients should change.
317
322
  - The agent can edit and publish content — give the plugin only to trusted editors.
318
323
 
324
+ ## Reliability — never degrades or breaks the host Strapi
325
+
326
+ This plugin is built to be a good citizen: installing it must never slow down or take
327
+ down the host app. Concrete guarantees:
328
+
329
+ - **Can't crash boot.** `register()` degrades gracefully if the native MCP server / i18n /
330
+ OpenAI key are absent (logs a warning, disables the feature). MCP tools register inside a
331
+ per-tool `try/catch`, so a single bad tool can never abort the others or the boot.
332
+ `destroy()` is best-effort.
333
+ - **Can't blank the admin.** The global overlay (floating chat + preview) mounts inside a
334
+ React **error boundary** in its own root, after an SSR/double-mount guard; any render
335
+ error just hides the overlay, leaving the Strapi admin fully intact. Lingering preview
336
+ layout styles are reset on load, and screen/mic capture is stopped on unmount.
337
+ - **Provisioning is fail-safe.** Generated schemas are **validated before any file is
338
+ written** (kind/attributes/known types/relation targets) and writes are **all-or-nothing**
339
+ — a malformed manifest can never leave Strapi with a broken, unbootable schema. Generation
340
+ stays additive (never touches existing types), dev-only, with hardened zip-slip protection.
341
+ - **Won't become a bottleneck.** Every outbound call (OpenAI, MCP) has a **timeout** so a
342
+ slow upstream can't hold a request open; recursive content search has depth caps and a
343
+ result cap; tool outputs are size-capped before going back to the model.
344
+
319
345
  ## License
320
346
 
321
347
  [MIT](./LICENSE) © Raul Balestra
@@ -197,26 +197,16 @@ export const AdminOverlays = () => {
197
197
  <FloatingChat
198
198
  previewOn={previewOn}
199
199
  previewUrl={liveHref}
200
+ draft={draftPreview}
200
201
  onTogglePreview={() => setPreviewOn((v) => !v)}
201
- onReply={async (didWrite) => {
202
+ onReply={(didWrite) => {
202
203
  if (!previewOn) return;
203
- // Houve edição no Strapi: re-sincroniza o snapshot do frontend para o
204
- // preview refletir (a fonte da verdade é o Strapi). Se não for snapshot
205
- // ou não houver provisão, o integrate é no-op e recarregamos.
206
- if (didWrite) {
207
- try {
208
- setRunError(false);
209
- setRunText('Sincronizando alterações…');
210
- setRunLoading(true);
211
- const { post } = getFetchClient();
212
- await post('/mcp-chat/frontend/integrate', {});
213
- } catch {
214
- /* sem integração: segue só com reload */
215
- } finally {
216
- setRunLoading(false);
217
- }
218
- }
219
- reload();
204
+ // Modelo LIVE-FETCH: a Strapi é a fonte da verdade e o front SEMPRE
205
+ // busca dela. A edição do chat foi gravada na Strapi (no campo
206
+ // exato, via editar_campo) aqui recarregamos o preview para o
207
+ // front re-buscar (em draft quando o toggle de rascunho está ligado).
208
+ // NÃO reescrevemos arquivos do frontend (nada de snapshot/integrate).
209
+ if (didWrite) reload();
220
210
  }}
221
211
  />
222
212
  </>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Error boundary que ISOLA as sobreposições do plugin (chat + preview) do resto
3
+ * do admin do Strapi. Se algo dentro renderizar com erro, capturamos aqui e
4
+ * renderizamos `null` — o overlay some, mas o admin do Strapi continua intacto.
5
+ * Sem isto, um erro de render num root React próprio poderia quebrar a página.
6
+ */
7
+ import { Component, type ReactNode } from 'react';
8
+
9
+ type Props = { children: ReactNode };
10
+ type State = { failed: boolean };
11
+
12
+ export class ErrorBoundary extends Component<Props, State> {
13
+ state: State = { failed: false };
14
+
15
+ static getDerivedStateFromError(): State {
16
+ return { failed: true };
17
+ }
18
+
19
+ componentDidCatch(error: unknown) {
20
+ // Apenas loga; nunca propaga para o admin.
21
+ // eslint-disable-next-line no-console
22
+ console.error('[mcp-chat] overlay desativado após erro de render:', error);
23
+ }
24
+
25
+ render() {
26
+ if (this.state.failed) return null;
27
+ return this.props.children;
28
+ }
29
+ }
@@ -13,6 +13,8 @@ type Pos = { x: number; y: number };
13
13
  type Props = {
14
14
  previewOn: boolean;
15
15
  previewUrl: string;
16
+ /** modo do preview: true = rascunho (draft), false = publicado (live). */
17
+ draft?: boolean;
16
18
  onTogglePreview: () => void;
17
19
  /** chamado após uma resposta; didWrite indica que houve edição no Strapi. */
18
20
  onReply: (didWrite: boolean) => void;
@@ -28,6 +30,7 @@ const STR: Record<Lang, Record<string, string>> = {
28
30
  rec: '🎤 Enviar áudio', recStop: '⏹ Parar áudio', recTitle: 'Gravar áudio e enviar (transcreve e manda)',
29
31
  previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Abrir/fechar o preview do site ao lado da Strapi',
30
32
  voiceOn: '🔊 Voz: ON', voiceOff: '🔈 Voz: OFF', voiceTitle: 'Ler as respostas em voz alta (TTS)',
33
+ stopVoice: '⏹ Parar voz', stopVoiceTitle: 'Parar a voz que está tocando',
31
34
  pubOn: '🚀 Publicar: ON', pubOff: '📝 Rascunho', pubTitle: 'Auto-publicar OFF = a IA só salva rascunho (você revisa e publica). ON = publica direto no site.',
32
35
  shareOn: '🛑 Parar tela', shareOff: '🖥 Compart. tela', shareTitle: 'Compartilhar a tela com a IA',
33
36
  langTitle: 'Idioma do chat e da voz (PT-BR ↔ English)',
@@ -45,6 +48,7 @@ const STR: Record<Lang, Record<string, string>> = {
45
48
  rec: '🎤 Send audio', recStop: '⏹ Stop audio', recTitle: 'Record audio and send (transcribes and sends)',
46
49
  previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Toggle the site preview next to Strapi',
47
50
  voiceOn: '🔊 Voice: ON', voiceOff: '🔈 Voice: OFF', voiceTitle: 'Read replies out loud (TTS)',
51
+ stopVoice: '⏹ Stop voice', stopVoiceTitle: 'Stop the voice currently playing',
48
52
  pubOn: '🚀 Publish: ON', pubOff: '📝 Draft', pubTitle: 'Auto-publish OFF = the AI only saves a draft (you review and publish). ON = publishes straight to the site.',
49
53
  shareOn: '🛑 Stop screen', shareOff: '🖥 Share screen', shareTitle: 'Share your screen with the AI',
50
54
  langTitle: 'Chat and voice language (PT-BR ↔ English)',
@@ -59,7 +63,7 @@ const STR: Record<Lang, Record<string, string>> = {
59
63
  },
60
64
  };
61
65
 
62
- export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }: Props) => {
66
+ export const FloatingChat = ({ previewOn, previewUrl, draft = false, onTogglePreview, onReply }: Props) => {
63
67
  const [open, setOpen] = useState(false);
64
68
  const [pos, setPos] = useState<Pos | null>(null); // null = ancorado à direita
65
69
  const [messages, setMessages] = useState<Msg[]>([]);
@@ -67,6 +71,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
67
71
  const [loading, setLoading] = useState(false);
68
72
  const [sharing, setSharing] = useState(false);
69
73
  const [voiceOn, setVoiceOn] = useState(false);
74
+ const [speaking, setSpeaking] = useState(false);
70
75
  const [recording, setRecording] = useState(false);
71
76
  const [autoPublish, setAutoPublish] = useState<boolean>(() => {
72
77
  try { return localStorage.getItem('mcp-chat-autopublish') === '1'; } catch { return false; }
@@ -87,6 +92,7 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
87
92
  const chunksRef = useRef<Blob[]>([]);
88
93
  const bodyRef = useRef<HTMLDivElement | null>(null);
89
94
  const dragRef = useRef<{ dx: number; dy: number } | null>(null);
95
+ const audioRef = useRef<HTMLAudioElement | null>(null);
90
96
 
91
97
  // ── Drag ────────────────────────────────────────────────────────────────
92
98
  const onHeaderDown = (e: React.MouseEvent) => {
@@ -113,6 +119,46 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
113
119
  if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
114
120
  }, [messages, loading]);
115
121
 
122
+ // ── Cleanup ao desmontar: encerra captura de tela / microfone (sem vazar) ──
123
+ useEffect(() => {
124
+ return () => {
125
+ try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
126
+ try { recorderRef.current?.stop(); } catch { /* noop */ }
127
+ };
128
+ }, []);
129
+
130
+ // ── Logout: limpa a conversa e esconde o chat ──────────────────────────────
131
+ // O overlay vive num React root próprio (fora da árvore do admin), então não dá
132
+ // pra usar useAuth. Detectamos a sessão pela ROTA: a tela de login/logout do
133
+ // Strapi é /admin/auth/... Ao cair nela (logout), zeramos a conversa, encerramos
134
+ // áudio/gravação/compartilhamento e escondemos o FAB — o chat só existe logado.
135
+ const [loggedOut, setLoggedOut] = useState(() => /\/auth(\/|$)/.test(window.location.pathname));
136
+ useEffect(() => {
137
+ const check = () => {
138
+ const out = /\/auth(\/|$)/.test(window.location.pathname);
139
+ setLoggedOut((prev) => {
140
+ if (out && !prev) {
141
+ // acabou de deslogar → limpa tudo que é da sessão.
142
+ setMessages([]);
143
+ setInput('');
144
+ setError(null);
145
+ setOpen(false);
146
+ stopTTS();
147
+ try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
148
+ try { recorderRef.current?.stop(); } catch { /* noop */ }
149
+ setSharing(false);
150
+ setRecording(false);
151
+ }
152
+ return out;
153
+ });
154
+ };
155
+ check();
156
+ const id = window.setInterval(check, 1000);
157
+ window.addEventListener('popstate', check);
158
+ return () => { window.clearInterval(id); window.removeEventListener('popstate', check); };
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, []);
161
+
116
162
  // ── Screenshare ───────────────────────────────────────────────────────────
117
163
  const startShare = async () => {
118
164
  setError(null);
@@ -151,15 +197,34 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
151
197
  };
152
198
 
153
199
  // ── TTS ────────────────────────────────────────────────────────────────────
200
+ // Para qualquer áudio em reprodução (botão ⏹ ou ao iniciar uma nova fala).
201
+ const stopTTS = () => {
202
+ const a = audioRef.current;
203
+ if (a) {
204
+ try { a.pause(); a.currentTime = 0; } catch { /* noop */ }
205
+ audioRef.current = null;
206
+ }
207
+ setSpeaking(false);
208
+ };
209
+
154
210
  const playTTS = async (text: string) => {
155
211
  try {
212
+ stopTTS(); // não sobrepõe falas
156
213
  const { post } = getFetchClient();
157
214
  const { data } = await post('/mcp-chat/tts', { text });
158
215
  const audio = new Audio(`data:${data.content_type};base64,${data.audio_base64}`);
159
- await audio.play().catch(() => undefined);
160
- } catch { /* voz é opcional */ }
216
+ audioRef.current = audio;
217
+ const done = () => { if (audioRef.current === audio) audioRef.current = null; setSpeaking(false); };
218
+ audio.addEventListener('ended', done);
219
+ audio.addEventListener('error', done);
220
+ setSpeaking(true);
221
+ await audio.play().catch(() => done());
222
+ } catch { setSpeaking(false); /* voz é opcional */ }
161
223
  };
162
224
 
225
+ // para a voz se o componente desmontar.
226
+ useEffect(() => () => stopTTS(), []);
227
+
163
228
  // ── Enviar ──────────────────────────────────────────────────────────────────
164
229
  const sendMessage = async (text: string) => {
165
230
  if (!text || loading) return;
@@ -177,6 +242,8 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
177
242
  // Página que o usuário está olhando no preview (se aberto). Dá à IA o
178
243
  // contexto do "isso aqui" sem precisar varrer o site inteiro.
179
244
  previewUrl: previewOn ? previewUrl : null,
245
+ // A busca de texto segue o modo do preview: draft -> rascunhos, live -> publicado.
246
+ previewStatus: draft ? 'draft' : 'published',
180
247
  // Draft-first: por padrão a IA só salva rascunho; só publica com isto ON.
181
248
  autoPublish,
182
249
  });
@@ -254,6 +321,9 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
254
321
  fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
255
322
  });
256
323
 
324
+ // deslogado: o chat não existe (já foi limpo no efeito acima).
325
+ if (loggedOut) return null;
326
+
257
327
  if (!open) {
258
328
  return (
259
329
  <button
@@ -329,6 +399,11 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
329
399
  title={t.voiceTitle}>
330
400
  {voiceOn ? t.voiceOn : t.voiceOff}
331
401
  </button>
402
+ {speaking && (
403
+ <button style={btn(true)} onClick={stopTTS} title={t.stopVoiceTitle}>
404
+ {t.stopVoice}
405
+ </button>
406
+ )}
332
407
  <button style={btn(autoPublish)} onClick={() => setAutoPublish((v) => !v)}
333
408
  title={t.pubTitle}>
334
409
  {autoPublish ? t.pubOn : t.pubOff}
@@ -1,6 +1,25 @@
1
1
  import { createRoot } from 'react-dom/client';
2
2
  import { PLUGIN_ID } from './pluginId';
3
3
  import { AdminOverlays } from './components/AdminOverlays';
4
+ import { ErrorBoundary } from './components/ErrorBoundary';
5
+
6
+ // Flag de módulo: trava extra contra duplo-mount (além do guard por id no DOM).
7
+ let mounted = false;
8
+
9
+ /** Limpa estilos inline que o PreviewPanel possa ter deixado no #strapi caso uma
10
+ * sessão anterior tenha sido encerrada de forma abrupta — garante que o admin
11
+ * nunca apareça encolhido/quebrado ao carregar. */
12
+ const resetStrapiRootStyles = () => {
13
+ try {
14
+ const root = document.getElementById('strapi') as HTMLElement | null;
15
+ if (!root) return;
16
+ for (const p of ['width', 'maxWidth', 'transform', 'overflow'] as const) {
17
+ root.style[p] = '';
18
+ }
19
+ } catch {
20
+ /* noop */
21
+ }
22
+ };
4
23
 
5
24
  const PluginIcon = () => (
6
25
  <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -39,11 +58,26 @@ export default {
39
58
  // tocar na árvore do admin). É o único desvio das APIs documentadas e está
40
59
  // contido a este único ponto. `register()` acima usa só APIs oficiais
41
60
  // (addMenuLink + registerPlugin).
61
+ // Guarda de ambiente: só roda no browser (nunca em SSR/headless sem DOM).
62
+ if (typeof document === 'undefined' || typeof window === 'undefined') return;
42
63
  const ID = 'mcp-chat-fab-root';
43
- if (document.getElementById(ID)) return;
44
- const el = document.createElement('div');
45
- el.id = ID;
46
- document.body.appendChild(el);
47
- createRoot(el).render(<AdminOverlays />);
64
+ if (mounted || document.getElementById(ID)) return;
65
+ mounted = true;
66
+ try {
67
+ resetStrapiRootStyles();
68
+ const el = document.createElement('div');
69
+ el.id = ID;
70
+ document.body.appendChild(el);
71
+ // ErrorBoundary garante que um erro de render do overlay nunca derrube o admin.
72
+ createRoot(el).render(
73
+ <ErrorBoundary>
74
+ <AdminOverlays />
75
+ </ErrorBoundary>
76
+ );
77
+ } catch (e) {
78
+ // Em último caso, falhar em montar o overlay não pode quebrar o admin.
79
+ // eslint-disable-next-line no-console
80
+ console.error('[mcp-chat] falha ao montar overlays (admin segue normal):', e);
81
+ }
48
82
  },
49
83
  };