strapi-plugin-mcp-chat 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -0
  3. package/admin/src/components/AdminOverlays.tsx +190 -0
  4. package/admin/src/components/FloatingChat.tsx +370 -0
  5. package/admin/src/components/PreviewPanel.tsx +188 -0
  6. package/admin/src/index.tsx +49 -0
  7. package/admin/src/pages/App.tsx +14 -0
  8. package/admin/src/pages/HomePage.tsx +333 -0
  9. package/admin/src/pages/ProvisionPage.tsx +391 -0
  10. package/admin/src/pluginId.ts +1 -0
  11. package/dist/server/index.js +3511 -0
  12. package/package.json +77 -0
  13. package/server/src/content-tools.ts +520 -0
  14. package/server/src/controllers/audio.ts +45 -0
  15. package/server/src/controllers/chat.ts +22 -0
  16. package/server/src/controllers/frontend.ts +310 -0
  17. package/server/src/index.ts +43 -0
  18. package/server/src/mcp/index.ts +24 -0
  19. package/server/src/mcp/tools/buscar-texto.ts +28 -0
  20. package/server/src/mcp/tools/criar-locale.ts +30 -0
  21. package/server/src/mcp/tools/editar-campo.ts +39 -0
  22. package/server/src/mcp/tools/habilitar-i18n.ts +33 -0
  23. package/server/src/mcp/tools/index.ts +17 -0
  24. package/server/src/mcp/tools/listar-locales.ts +27 -0
  25. package/server/src/mcp/tools/publicar.ts +31 -0
  26. package/server/src/mcp/tools/traduzir.ts +36 -0
  27. package/server/src/mcp/types.ts +11 -0
  28. package/server/src/mcp-client.ts +96 -0
  29. package/server/src/provision/adapters.ts +91 -0
  30. package/server/src/provision/enable-i18n.ts +129 -0
  31. package/server/src/provision/generate.ts +216 -0
  32. package/server/src/provision/infer.ts +495 -0
  33. package/server/src/provision/integrate.ts +963 -0
  34. package/server/src/provision/link.ts +203 -0
  35. package/server/src/provision/manifest.ts +281 -0
  36. package/server/src/provision/orchestrate.ts +236 -0
  37. package/server/src/provision/permissions.ts +58 -0
  38. package/server/src/provision/runner.ts +176 -0
  39. package/server/src/provision/seed.ts +115 -0
  40. package/server/src/provision/translate.ts +153 -0
  41. package/server/src/provision/types-gen.ts +117 -0
  42. package/server/src/provision/write.ts +136 -0
  43. package/server/src/register.ts +17 -0
  44. package/server/src/routes/index.ts +66 -0
  45. package/server/src/services/audio.ts +53 -0
  46. package/server/src/services/chat.ts +263 -0
@@ -0,0 +1,310 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import JSZip from 'jszip';
4
+ import { stageProvision, getProvisionStatus } from '../provision/orchestrate';
5
+ import { inferManifest } from '../provision/infer';
6
+ import { startFrontend, getRunStatus } from '../provision/runner';
7
+ import { integrateFrontend } from '../provision/integrate';
8
+ import { validateManifest } from '../provision/manifest';
9
+ import type { LinkContext } from '../provision/adapters';
10
+
11
+ /**
12
+ * Controller de provisão de frontend. Fluxo em DUAS etapas para o cenário
13
+ * Figma/Lovable (frontend sem manifest):
14
+ *
15
+ * 1) analyze: recebe o .zip → extrai para a pasta irmã → se houver
16
+ * strapi.manifest.json usa ele, SENÃO infere via IA a partir do código.
17
+ * Devolve o manifest proposto para o usuário REVISAR. Não cria nada ainda.
18
+ * 2) provision: recebe o manifest confirmado (possivelmente editado) → grava no
19
+ * projeto → cria content-types + agenda seed/link → reinicia a Strapi.
20
+ *
21
+ * Segurança: dev-only (escrita de schema é dev-only), proteção contra zip-slip,
22
+ * destino aditivo (não sobrescreve pasta existente) e o frontendDir da etapa 2 é
23
+ * validado para ficar dentro da pasta-pai da app Strapi. Nunca executa o código.
24
+ */
25
+
26
+ const MANIFEST_NAME = 'strapi.manifest.json';
27
+
28
+ function ensureInside(base: string, target: string): boolean {
29
+ const n = path.normalize(target);
30
+ return n === base || n.startsWith(base + path.sep);
31
+ }
32
+
33
+ /** normaliza um nome livre para o formato kebab exigido pelo manifest. */
34
+ function toKebab(input: string): string {
35
+ const s = (input || 'frontend')
36
+ .toLowerCase()
37
+ .replace(/\.zip$/, '')
38
+ .replace(/[^a-z0-9]+/g, '-')
39
+ .replace(/^-+|-+$/g, '')
40
+ .replace(/^[^a-z]+/, '');
41
+ return s || 'frontend';
42
+ }
43
+
44
+ function devOnly(ctx: any): boolean {
45
+ if (process.env.NODE_ENV !== 'development') {
46
+ ctx.badRequest(
47
+ 'A provisão de frontend só funciona em desenvolvimento (geração de content-types é dev-only).'
48
+ );
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+
54
+ export default {
55
+ /** Etapa 1: extrai e descobre/infere o manifest (sem criar nada). */
56
+ async analyze(ctx: any) {
57
+ const strapi = ctx.strapi ?? (global as any).strapi;
58
+ if (!devOnly(ctx)) return;
59
+
60
+ // 1) arquivo enviado
61
+ const files = ctx.request.files || {};
62
+ const file = files.frontend || files.file || Object.values(files)[0];
63
+ if (!file) return ctx.badRequest('Envie o .zip do frontend no campo "frontend".');
64
+ const filepath = (file as any).filepath || (file as any).path;
65
+ if (!filepath) return ctx.badRequest('Arquivo inválido.');
66
+ const originalName = (file as any).originalFilename || (file as any).name || 'frontend';
67
+
68
+ // 2) abre o zip
69
+ let zip: JSZip;
70
+ try {
71
+ zip = await JSZip.loadAsync(fs.readFileSync(filepath));
72
+ } catch (e: any) {
73
+ return ctx.badRequest(`Zip inválido: ${e?.message ?? e}`);
74
+ }
75
+
76
+ // detecta um prefixo de pasta raiz comum (ex.: "meu-app/") para achatar
77
+ const entryNames = Object.keys(zip.files);
78
+ const manifestEntry = entryNames.find(
79
+ (p) => path.basename(p) === MANIFEST_NAME && !zip.files[p].dir
80
+ );
81
+ let rootPrefix = '';
82
+ if (manifestEntry) {
83
+ rootPrefix = manifestEntry.slice(0, manifestEntry.length - MANIFEST_NAME.length);
84
+ } else {
85
+ // se TODO mundo compartilha a mesma primeira pasta, usa-a como prefixo
86
+ const tops = new Set(
87
+ entryNames.filter((n) => n.includes('/')).map((n) => n.slice(0, n.indexOf('/') + 1))
88
+ );
89
+ if (tops.size === 1) rootPrefix = [...tops][0];
90
+ }
91
+
92
+ // nome do destino: nome do arquivo enviado (kebab)
93
+ const name = toKebab(originalName);
94
+ const strapiAppDir: string = strapi.dirs.app.root;
95
+ const frontendDir = path.resolve(strapiAppDir, '..', name);
96
+
97
+ // 3) destino aditivo
98
+ if (fs.existsSync(frontendDir) && fs.readdirSync(frontendDir).length > 0) {
99
+ return ctx.badRequest(
100
+ `A pasta de destino já existe e não está vazia: ${frontendDir}. Renomeie o .zip ou remova a pasta.`
101
+ );
102
+ }
103
+
104
+ // 4) extrai (com proteção contra zip-slip)
105
+ try {
106
+ fs.mkdirSync(frontendDir, { recursive: true });
107
+ for (const entryName of entryNames) {
108
+ const entry = zip.files[entryName];
109
+ const rel =
110
+ rootPrefix && entryName.startsWith(rootPrefix)
111
+ ? entryName.slice(rootPrefix.length)
112
+ : entryName;
113
+ if (!rel) continue;
114
+ const dest = path.join(frontendDir, rel);
115
+ if (!ensureInside(frontendDir, dest)) {
116
+ throw new Error(`entrada perigosa no zip bloqueada: ${entryName}`);
117
+ }
118
+ if (entry.dir) {
119
+ fs.mkdirSync(dest, { recursive: true });
120
+ } else {
121
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
122
+ fs.writeFileSync(dest, await entry.async('nodebuffer'));
123
+ }
124
+ }
125
+ } catch (e: any) {
126
+ return ctx.internalServerError(`Falha ao extrair: ${e?.message ?? e}`);
127
+ }
128
+
129
+ // 5) acha ou INFERE o manifest a partir do código
130
+ const inf = await inferManifest(strapi, frontendDir, { name });
131
+
132
+ ctx.body = {
133
+ ok: inf.ok,
134
+ frontendDir,
135
+ inferred: inf.inferred,
136
+ framework: inf.framework,
137
+ filesAnalyzed: inf.filesAnalyzed,
138
+ // manifest cru (mesmo se inválido, para o usuário revisar/editar)
139
+ manifest: inf.rawManifest ?? inf.manifest ?? null,
140
+ warnings: inf.warnings,
141
+ errors: inf.errors,
142
+ message: inf.ok
143
+ ? inf.inferred
144
+ ? 'Manifest inferido a partir do código. Revise e confirme para provisionar.'
145
+ : 'Manifest encontrado no projeto. Revise e confirme para provisionar.'
146
+ : 'Não foi possível obter um manifest válido. Edite-o abaixo e tente provisionar.',
147
+ };
148
+ },
149
+
150
+ /** Etapa 2: provisiona a partir do manifest confirmado. */
151
+ async provision(ctx: any) {
152
+ const strapi = ctx.strapi ?? (global as any).strapi;
153
+ if (!devOnly(ctx)) return;
154
+
155
+ const body = ctx.request.body || {};
156
+ const rawManifest = body.manifest;
157
+ const frontendDir: string = body.frontendDir;
158
+ if (!rawManifest) return ctx.badRequest('Envie o "manifest".');
159
+ if (!frontendDir || !path.isAbsolute(frontendDir)) {
160
+ return ctx.badRequest('frontendDir ausente ou inválido.');
161
+ }
162
+
163
+ const strapiAppDir: string = strapi.dirs.app.root;
164
+ const apiRoot: string = strapi.dirs.app.api;
165
+ const parent = path.resolve(strapiAppDir, '..');
166
+
167
+ // segurança: frontendDir tem que ser uma pasta irmã já existente
168
+ if (!ensureInside(parent, frontendDir) || frontendDir === parent) {
169
+ return ctx.badRequest('frontendDir fora da pasta permitida.');
170
+ }
171
+ if (!fs.existsSync(frontendDir)) {
172
+ return ctx.badRequest('frontendDir não existe (rode a análise primeiro).');
173
+ }
174
+
175
+ // valida o manifest confirmado
176
+ const v = validateManifest(rawManifest);
177
+ if (!v.ok) {
178
+ return ctx.badRequest({ message: 'Manifest inválido', errors: v.errors });
179
+ }
180
+
181
+ // persiste o manifest no projeto (fica versionável e reaproveitável)
182
+ try {
183
+ fs.writeFileSync(
184
+ path.join(frontendDir, MANIFEST_NAME),
185
+ JSON.stringify(v.data, null, 2),
186
+ 'utf8'
187
+ );
188
+ } catch (e: any) {
189
+ return ctx.internalServerError(`Não foi possível gravar o manifest: ${e?.message ?? e}`);
190
+ }
191
+
192
+ const port = strapi.config.get('server.port', 1337);
193
+ let locales: string[] = [];
194
+ try {
195
+ locales = ((await strapi.plugin('i18n').service('locales').find()) || []).map((l: any) => l.code);
196
+ } catch {
197
+ /* i18n off — segue sem locales */
198
+ }
199
+ const context: LinkContext = { strapiUrl: `http://localhost:${port}`, locales };
200
+
201
+ const staged = stageProvision(strapi, {
202
+ rawManifest: v.data,
203
+ apiRoot,
204
+ frontendDir,
205
+ strapiAppDir,
206
+ context,
207
+ });
208
+
209
+ if (!staged.ok) {
210
+ return ctx.badRequest({
211
+ message: 'Provisão falhou',
212
+ validation: staged.validation,
213
+ errors: staged.errors,
214
+ });
215
+ }
216
+
217
+ ctx.body = {
218
+ ok: true,
219
+ frontendDir,
220
+ contentTypes: staged.write?.written.filter((f) => f.endsWith('schema.json')).length ?? 0,
221
+ skipped: staged.write?.skipped ?? [],
222
+ willReload: staged.willReload,
223
+ message: staged.willReload
224
+ ? 'Provisão agendada. A Strapi vai reiniciar e então criar o conteúdo e ligar o preview.'
225
+ : 'Nenhuma content-type nova (já existiam).',
226
+ };
227
+
228
+ // NÃO chamamos strapi.reload() aqui: a provisão é dev-only e, em `develop`, o
229
+ // próprio file watcher da Strapi reinicia ao detectar os novos schema.json.
230
+ // Disparar um reload manual junto causava um DOUBLE restart e matava o worker
231
+ // (EPIPE) na 5.48. O watcher cuida do restart; o pós-restart roda no bootstrap.
232
+ },
233
+
234
+ /** Status da provisão — a UI faz polling após confirmar. */
235
+ status(ctx: any) {
236
+ const strapi = ctx.strapi ?? (global as any).strapi;
237
+ ctx.body = getProvisionStatus(strapi.dirs.app.root);
238
+ },
239
+
240
+ /**
241
+ * Roda o dev server do frontend provisionado. A UI chama isto ao ligar o
242
+ * preview pela 1ª vez após o upload. Sem body, usa o último provisionado.
243
+ */
244
+ async run(ctx: any) {
245
+ const strapi = ctx.strapi ?? (global as any).strapi;
246
+ if (!devOnly(ctx)) return;
247
+
248
+ const body = ctx.request.body || {};
249
+ let dir: string = body.dir;
250
+ let url: string = body.url;
251
+ if (!dir || !url) {
252
+ const st = getProvisionStatus(strapi.dirs.app.root);
253
+ if (st.done) {
254
+ dir = dir || st.done.frontendDir;
255
+ url = url || st.done.previewUrl;
256
+ }
257
+ }
258
+ if (!dir || !url) {
259
+ return ctx.badRequest('Nenhum frontend provisionado para rodar.');
260
+ }
261
+
262
+ // segurança: só dentro da pasta-pai da app
263
+ const parent = path.resolve(strapi.dirs.app.root, '..');
264
+ if (!ensureInside(parent, dir) || !fs.existsSync(dir)) {
265
+ return ctx.badRequest('Pasta do frontend inválida.');
266
+ }
267
+
268
+ ctx.body = await startFrontend(strapi, { dir, url });
269
+ },
270
+
271
+ /** Status do dev server do frontend (polling da UI). */
272
+ runStatus(ctx: any) {
273
+ ctx.body = getRunStatus();
274
+ },
275
+
276
+ /**
277
+ * Religa o frontend ao Strapi por SNAPSHOT: regenera o(s) arquivo(s) de dados
278
+ * com o conteúdo do Strapi, mantendo nomes de export e imagens originais.
279
+ * Usa o último provisionado por padrão.
280
+ */
281
+ async integrate(ctx: any) {
282
+ const strapi = ctx.strapi ?? (global as any).strapi;
283
+ if (!devOnly(ctx)) return;
284
+
285
+ const body = ctx.request.body || {};
286
+ let frontendDir: string = body.frontendDir;
287
+ if (!frontendDir) {
288
+ const st = getProvisionStatus(strapi.dirs.app.root);
289
+ frontendDir = st.done?.frontendDir || '';
290
+ }
291
+ if (!frontendDir) return ctx.badRequest('Nenhum frontend provisionado.');
292
+
293
+ const parent = path.resolve(strapi.dirs.app.root, '..');
294
+ if (!ensureInside(parent, frontendDir) || !fs.existsSync(frontendDir)) {
295
+ return ctx.badRequest('Pasta do frontend inválida.');
296
+ }
297
+
298
+ // lê o manifest persistido no projeto
299
+ let manifest: any;
300
+ try {
301
+ manifest = JSON.parse(fs.readFileSync(path.join(frontendDir, MANIFEST_NAME), 'utf8'));
302
+ } catch {
303
+ return ctx.badRequest('Manifest do projeto não encontrado (rode a provisão primeiro).');
304
+ }
305
+ const v = validateManifest(manifest);
306
+ if (!v.ok) return ctx.badRequest({ message: 'Manifest inválido', errors: v.errors });
307
+
308
+ ctx.body = await integrateFrontend(strapi, { frontendDir, manifest: v.data });
309
+ },
310
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Server entry do plugin mcp-chat.
3
+ */
4
+
5
+ import chat from './controllers/chat';
6
+ import audio from './controllers/audio';
7
+ import frontend from './controllers/frontend';
8
+ import chatService from './services/chat';
9
+ import audioService from './services/audio';
10
+ import routes from './routes';
11
+ import register from './register';
12
+ import { runPendingProvision } from './provision/orchestrate';
13
+ import { stopFrontend } from './provision/runner';
14
+
15
+ export default {
16
+ register,
17
+ async bootstrap({ strapi }: { strapi: any }) {
18
+ // Após um restart causado por um upload de frontend, conclui a provisão:
19
+ // semeia o conteúdo e liga o preview. Idempotente (no-op se não há pendência).
20
+ try {
21
+ const r = await runPendingProvision(strapi, strapi.dirs.app.root);
22
+ if (r.ran) {
23
+ strapi.log.info(
24
+ `[mcp-chat] provisão concluída: seed=${JSON.stringify(r.seed?.created ?? [])} link=${r.link?.previewAction ?? 'n/a'}`
25
+ );
26
+ if (r.errors.length) strapi.log.warn(`[mcp-chat] provisão com avisos: ${r.errors.join('; ')}`);
27
+ }
28
+ } catch (e: any) {
29
+ strapi.log.error(`[mcp-chat] runPendingProvision falhou: ${e?.message ?? e}`);
30
+ }
31
+ },
32
+ destroy() {
33
+ // encerra o dev server do frontend (se foi iniciado pelo preview).
34
+ stopFrontend();
35
+ },
36
+ config: {
37
+ default: {},
38
+ validator() {},
39
+ },
40
+ controllers: { chat, audio, frontend },
41
+ routes,
42
+ services: { chat: chatService, audio: audioService },
43
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Registra as tools de conteúdo do plugin no MCP server NATIVO da Strapi
3
+ * (>= 5.47.0). Estrutura modular inspirada no padrão do exemplo do Paul
4
+ * Bratslavsky: cada tool é um módulo em ./tools/*, agregado em ./tools/index.ts,
5
+ * e aqui passamos por um loop chamando `tool.register(registerTool, strapi)`.
6
+ *
7
+ * Deve rodar no `register()` do plugin, ANTES de o MCP server iniciar.
8
+ */
9
+ import { tools } from './tools';
10
+
11
+ export const registerMcpTools = (strapi: any) => {
12
+ const mcp = strapi?.ai?.mcp;
13
+ const enabled = typeof mcp?.isEnabled === 'function' ? mcp.isEnabled() : !!mcp?.registerTool;
14
+ if (!mcp || typeof mcp.registerTool !== 'function' || !enabled) {
15
+ strapi.log.warn(
16
+ '[mcp-chat] MCP nativo indisponível/desligado — tools NÃO registradas. ' +
17
+ 'Requer Strapi >= 5.47.0 com `mcp: { enabled: true }` em config/server.'
18
+ );
19
+ return;
20
+ }
21
+ const { registerTool } = mcp;
22
+ for (const tool of tools) tool.register(registerTool, strapi);
23
+ strapi.log.info(`[mcp-chat] ${tools.length} tools registradas no MCP nativo (mcp_chat_*).`);
24
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_buscar_texto',
9
+ title: 'Search text across content (deep)',
10
+ description:
11
+ 'Search a phrase across ALL content-types, single types, components and dynamic zones (recursive, substring). Returns matches with a `path` (e.g. ["dynamic_zone",2,"heading"]) to pass to mcp_chat_editar_campo.',
12
+ resolveInputSchema: () => z.object({ termo: z.string() }),
13
+ resolveOutputSchema: () =>
14
+ z.object({
15
+ total: z.number().optional(),
16
+ resultados: z.array(z.any()).optional(),
17
+ erro: z.string().optional(),
18
+ }),
19
+ auth: { policies: [{ action: 'plugin::content-manager.explorer.read' }] },
20
+ createHandler: (strapi: any) => async ({ args }: any) => {
21
+ const r = await createContentTools(strapi).buscarTexto(args?.termo);
22
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
23
+ },
24
+ });
25
+ },
26
+ };
27
+
28
+ export default tool;
@@ -0,0 +1,30 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_criar_locale',
9
+ title: 'Create an i18n locale',
10
+ description:
11
+ 'Create a locale (language). `code` must be a valid ISO code (e.g. "pt-BR", "es"). Idempotent: returns ok if it already exists.',
12
+ resolveInputSchema: () => z.object({ code: z.string(), name: z.string().optional() }),
13
+ resolveOutputSchema: () =>
14
+ z.object({
15
+ ok: z.boolean().optional(),
16
+ code: z.string().optional(),
17
+ name: z.string().optional(),
18
+ existed: z.boolean().optional(),
19
+ erro: z.string().optional(),
20
+ }),
21
+ auth: { policies: [{ action: 'plugin::i18n.locale.create' }] },
22
+ createHandler: (strapi: any) => async ({ args }: any) => {
23
+ const r = await createContentTools(strapi).criarLocale(args);
24
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
25
+ },
26
+ });
27
+ },
28
+ };
29
+
30
+ export default tool;
@@ -0,0 +1,39 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_editar_campo',
9
+ title: 'Edit a (possibly nested) field',
10
+ description:
11
+ 'Edit a field value (saved as draft), including text nested in components/dynamic zones. Pass the `path` exactly as returned by mcp_chat_buscar_texto; for a simple top-level field you may use `campo`.',
12
+ resolveInputSchema: () =>
13
+ z.object({
14
+ uid: z.string(),
15
+ documentId: z.string(),
16
+ path: z.array(z.union([z.string(), z.number()])).optional(),
17
+ campo: z.string().optional(),
18
+ novo_valor: z.string(),
19
+ locale: z.string().optional(),
20
+ }),
21
+ resolveOutputSchema: () =>
22
+ z.object({
23
+ ok: z.boolean().optional(),
24
+ uid: z.string().optional(),
25
+ documentId: z.string().optional(),
26
+ path: z.array(z.any()).optional(),
27
+ novo_valor: z.string().optional(),
28
+ erro: z.string().optional(),
29
+ }),
30
+ auth: { policies: [{ action: 'plugin::content-manager.explorer.update' }] },
31
+ createHandler: (strapi: any) => async ({ args }: any) => {
32
+ const r = await createContentTools(strapi).editarCampo(args);
33
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
34
+ },
35
+ });
36
+ },
37
+ };
38
+
39
+ export default tool;
@@ -0,0 +1,33 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { enableI18n } from '../../provision/enable-i18n';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_habilitar_i18n',
9
+ title: 'Enable i18n on a content-type',
10
+ description:
11
+ 'Enable translation on content-types not localized yet: marks the content-type and its textual fields/components as localized. Required before translating content provisioned without i18n. Omit `uid` (or pass "*") to enable ALL content-types at once. Edits the schema (dev-only); Strapi restarts.',
12
+ resolveInputSchema: () =>
13
+ z.object({ uid: z.string().optional(), campos: z.array(z.string()).optional() }),
14
+ resolveOutputSchema: () =>
15
+ z.object({
16
+ ok: z.boolean().optional(),
17
+ uid: z.string().optional(),
18
+ campos: z.array(z.string()).optional(),
19
+ contentTypes: z.array(z.any()).optional(),
20
+ total: z.number().optional(),
21
+ restart: z.boolean().optional(),
22
+ erro: z.string().optional(),
23
+ }),
24
+ auth: { policies: [{ action: 'plugin::content-type-builder.read' }] },
25
+ createHandler: (strapi: any) => async ({ args }: any) => {
26
+ const r = enableI18n({ strapi, uid: args?.uid, campos: args?.campos });
27
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
28
+ },
29
+ });
30
+ },
31
+ };
32
+
33
+ export default tool;
@@ -0,0 +1,17 @@
1
+ import buscarTexto from './buscar-texto';
2
+ import editarCampo from './editar-campo';
3
+ import publicar from './publicar';
4
+ import listarLocales from './listar-locales';
5
+ import criarLocale from './criar-locale';
6
+ import traduzir from './traduzir';
7
+ import habilitarI18n from './habilitar-i18n';
8
+
9
+ export const tools = [
10
+ buscarTexto,
11
+ editarCampo,
12
+ publicar,
13
+ listarLocales,
14
+ criarLocale,
15
+ traduzir,
16
+ habilitarI18n,
17
+ ];
@@ -0,0 +1,27 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_listar_locales',
9
+ title: 'List i18n locales',
10
+ description: 'List the configured locales (languages) and which one is the default.',
11
+ resolveInputSchema: () => z.object({}),
12
+ resolveOutputSchema: () =>
13
+ z.object({
14
+ default: z.string().optional(),
15
+ locales: z.array(z.any()).optional(),
16
+ erro: z.string().optional(),
17
+ }),
18
+ auth: { policies: [{ action: 'plugin::content-manager.explorer.read' }] },
19
+ createHandler: (strapi: any) => async () => {
20
+ const r = await createContentTools(strapi).listarLocales();
21
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
22
+ },
23
+ });
24
+ },
25
+ };
26
+
27
+ export default tool;
@@ -0,0 +1,31 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
4
+
5
+ const tool: StrapiMcpToolModule = {
6
+ register(registerTool) {
7
+ registerTool({
8
+ name: 'mcp_chat_publicar',
9
+ title: 'Publish an entry',
10
+ description:
11
+ 'Publish an entry by uid + documentId, making the change visible on the site. Pass `locale` to publish a specific language, or "*" for all.',
12
+ resolveInputSchema: () =>
13
+ z.object({ uid: z.string(), documentId: z.string(), locale: z.string().optional() }),
14
+ resolveOutputSchema: () =>
15
+ z.object({
16
+ ok: z.boolean().optional(),
17
+ uid: z.string().optional(),
18
+ documentId: z.string().optional(),
19
+ status: z.string().optional(),
20
+ locale: z.string().optional(),
21
+ }),
22
+ auth: { policies: [{ action: 'plugin::content-manager.explorer.publish' }] },
23
+ createHandler: (strapi: any) => async ({ args }: any) => {
24
+ const r = await createContentTools(strapi).publicar(args);
25
+ return { content: [{ type: 'text', text: JSON.stringify(r) }], structuredContent: r };
26
+ },
27
+ });
28
+ },
29
+ };
30
+
31
+ export default tool;
@@ -0,0 +1,36 @@
1
+ import { z } from '@strapi/utils';
2
+ import type { StrapiMcpToolModule } from '../types';
3
+ import { createContentTools } from '../../content-tools';
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
+ });
33
+ },
34
+ };
35
+
36
+ export default tool;
@@ -0,0 +1,11 @@
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)`.
5
+ */
6
+
7
+ export type RegisterTool = (toolDef: Record<string, any>) => void;
8
+
9
+ export type StrapiMcpToolModule = {
10
+ register: (registerTool: RegisterTool, strapi: any) => void;
11
+ };