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,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Manifest } from './manifest';
4
+ import { adapterForManifest, type LinkContext } from './adapters';
5
+ import { generateTypes } from './types-gen';
6
+ import { apiUid } from './generate';
7
+
8
+ /**
9
+ * Link: conecta o frontend já instalado à Strapi.
10
+ * 1. Escreve o .env do framework (aditivo: nunca sobrescreve valor existente).
11
+ * 2. Gera os tipos TS a partir do manifest.
12
+ * 3. Gera a config de Preview da Strapi (handler que mapeia uid -> rota do front).
13
+ *
14
+ * Tudo aditivo e idempotente. Caminhos validados para ficarem dentro dos dirs
15
+ * informados.
16
+ */
17
+
18
+ export interface LinkOptions {
19
+ /** pasta do frontend já instalado. */
20
+ frontendDir: string;
21
+ /** pasta raiz da app Strapi (onde fica config/). */
22
+ strapiAppDir: string;
23
+ context: LinkContext;
24
+ dryRun?: boolean;
25
+ }
26
+
27
+ export interface LinkResult {
28
+ ok: boolean;
29
+ envFile: string;
30
+ envAdded: string[];
31
+ envPreserved: string[];
32
+ typesFile: string;
33
+ previewFile: string;
34
+ previewAction: 'created' | 'sidecar' | 'skipped';
35
+ errors: string[];
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // .env (merge aditivo)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function parseEnv(content: string): Record<string, string> {
43
+ const out: Record<string, string> = {};
44
+ for (const line of content.split('\n')) {
45
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=(.*)$/);
46
+ if (m) out[m[1]] = m[2];
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function mergeEnv(
52
+ existing: string,
53
+ next: Record<string, string>
54
+ ): { content: string; added: string[]; preserved: string[] } {
55
+ const current = parseEnv(existing);
56
+ const added: string[] = [];
57
+ const preserved: string[] = [];
58
+ const extra: string[] = [];
59
+
60
+ for (const [k, v] of Object.entries(next)) {
61
+ if (k in current) {
62
+ preserved.push(k); // respeita o que o usuário já tem
63
+ } else {
64
+ added.push(k);
65
+ extra.push(`${k}=${v}`);
66
+ }
67
+ }
68
+
69
+ let content = existing;
70
+ if (extra.length) {
71
+ const block =
72
+ '\n# adicionado pelo mcp-chat (link)\n' + extra.join('\n') + '\n';
73
+ content = existing.trimEnd() + '\n' + block;
74
+ }
75
+ return { content, added, preserved };
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // config de Preview da Strapi
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Gera o conteúdo de config/admin.ts com o Preview configurado: mapeia cada uid
84
+ * para a rota de preview declarada no manifest e monta a URL com o slug do doc.
85
+ */
86
+ export function buildPreviewConfig(manifest: Manifest): string {
87
+ const routes: Record<string, string> = {};
88
+ for (const ct of manifest.contentTypes) {
89
+ if (ct.preview?.route) routes[apiUid(ct.singularName)] = ct.preview.route;
90
+ }
91
+ const routesJson = JSON.stringify(routes, null, 2);
92
+
93
+ return `// Preview gerado pelo mcp-chat a partir do strapi.manifest.json.
94
+ // Mapa uid -> rota do frontend (placeholders :campo são preenchidos pelo doc).
95
+ const PREVIEW_ROUTES: Record<string, string> = ${routesJson};
96
+
97
+ export default ({ env }) => ({
98
+ auth: {
99
+ secret: env('ADMIN_JWT_SECRET'),
100
+ },
101
+ apiToken: { salt: env('API_TOKEN_SALT') },
102
+ transfer: { token: { salt: env('TRANSFER_TOKEN_SALT') } },
103
+ preview: {
104
+ enabled: true,
105
+ config: {
106
+ allowedOrigins: [env('CLIENT_URL', 'http://localhost:3000')],
107
+ async handler(uid: string, { documentId, locale, status }: any) {
108
+ const route = PREVIEW_ROUTES[uid];
109
+ if (!route) return null;
110
+ const doc = await strapi.documents(uid as any).findOne({ documentId, locale });
111
+ if (!doc) return null;
112
+ // substitui :campo pelos valores do documento (ex.: :slug)
113
+ const pathname = route.replace(/:([a-zA-Z0-9_]+)/g, (_m, f) =>
114
+ encodeURIComponent(String((doc as any)[f] ?? ''))
115
+ );
116
+ const clientUrl = env('CLIENT_URL', 'http://localhost:3000');
117
+ const secret = env('PREVIEW_SECRET', '');
118
+ const qs = new URLSearchParams({ secret, status: status ?? 'draft', path: pathname });
119
+ return \`\${clientUrl}/api/preview?\${qs.toString()}\`;
120
+ },
121
+ },
122
+ },
123
+ });
124
+ `;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // orquestração do link
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function ensureInside(base: string, target: string): boolean {
132
+ const n = path.normalize(target);
133
+ return n === base || n.startsWith(base + path.sep);
134
+ }
135
+
136
+ export function linkFrontend(
137
+ manifest: Manifest,
138
+ opts: LinkOptions
139
+ ): LinkResult {
140
+ const adapter = adapterForManifest(manifest);
141
+ const result: LinkResult = {
142
+ ok: false,
143
+ envFile: adapter.envFileName,
144
+ envAdded: [],
145
+ envPreserved: [],
146
+ typesFile: 'strapi-types.ts',
147
+ previewFile: 'config/admin.ts',
148
+ previewAction: 'skipped',
149
+ errors: [],
150
+ };
151
+
152
+ if (!path.isAbsolute(opts.frontendDir) || !path.isAbsolute(opts.strapiAppDir)) {
153
+ result.errors.push('frontendDir e strapiAppDir devem ser absolutos');
154
+ return result;
155
+ }
156
+
157
+ // 1) .env (aditivo)
158
+ try {
159
+ const envPath = path.join(opts.frontendDir, adapter.envFileName);
160
+ if (!ensureInside(opts.frontendDir, envPath)) throw new Error('env fora do frontendDir');
161
+ const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
162
+ const vars = adapter.buildEnv(opts.context);
163
+ const { content, added, preserved } = mergeEnv(existing, vars);
164
+ result.envAdded = added;
165
+ result.envPreserved = preserved;
166
+ if (!opts.dryRun && added.length) fs.writeFileSync(envPath, content, 'utf8');
167
+ } catch (e: any) {
168
+ result.errors.push(`env: ${e?.message ?? e}`);
169
+ }
170
+
171
+ // 2) tipos TS (regenerado sempre — arquivo é totalmente owned pelo gerador)
172
+ try {
173
+ const typesPath = path.join(opts.frontendDir, result.typesFile);
174
+ if (!ensureInside(opts.frontendDir, typesPath)) throw new Error('types fora do frontendDir');
175
+ if (!opts.dryRun) fs.writeFileSync(typesPath, generateTypes(manifest), 'utf8');
176
+ } catch (e: any) {
177
+ result.errors.push(`types: ${e?.message ?? e}`);
178
+ }
179
+
180
+ // 3) config de Preview (não-destrutivo)
181
+ try {
182
+ const adminPath = path.join(opts.strapiAppDir, 'config', 'admin.ts');
183
+ const content = buildPreviewConfig(manifest);
184
+ if (fs.existsSync(adminPath)) {
185
+ // não sobrescreve config existente: escreve sidecar e reporta
186
+ result.previewFile = 'config/admin.mcp-chat-preview.ts';
187
+ const sidecar = path.join(opts.strapiAppDir, 'config', 'admin.mcp-chat-preview.ts');
188
+ if (!opts.dryRun) fs.writeFileSync(sidecar, content, 'utf8');
189
+ result.previewAction = 'sidecar';
190
+ } else {
191
+ if (!opts.dryRun) {
192
+ fs.mkdirSync(path.dirname(adminPath), { recursive: true });
193
+ fs.writeFileSync(adminPath, content, 'utf8');
194
+ }
195
+ result.previewAction = 'created';
196
+ }
197
+ } catch (e: any) {
198
+ result.errors.push(`preview: ${e?.message ?? e}`);
199
+ }
200
+
201
+ result.ok = result.errors.length === 0;
202
+ return result;
203
+ }
@@ -0,0 +1,281 @@
1
+ import { z } from '@strapi/utils';
2
+
3
+ /**
4
+ * O CONTRATO.
5
+ *
6
+ * Todo frontend "abençoado" (Next ou TanStack) traz um `strapi.manifest.json`
7
+ * na raiz. O plugin NUNCA executa código vindo do upload — ele apenas lê e
8
+ * valida este manifest com o schema abaixo e, a partir dele, provisiona o
9
+ * backend (gera content-types, semeia conteúdo, liga o preview).
10
+ *
11
+ * Validar aqui, ANTES de tocar no disco, é o que torna o upload seguro e
12
+ * previsível ("nunca quebra"). Tudo que não casar com o schema é rejeitado
13
+ * com uma mensagem clara, e nada é escrito.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Nomes seguros
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** kebab-case, começa com letra, sem colidir com termos reservados da Strapi. */
21
+ const kebab = z
22
+ .string()
23
+ .min(1)
24
+ .max(48)
25
+ .regex(/^[a-z][a-z0-9-]*$/, 'use kebab-case (ex.: "produto", "post-blog")');
26
+
27
+ /** identificador de atributo: snake/camel simples, sem espaços. */
28
+ const attrKey = z
29
+ .string()
30
+ .min(1)
31
+ .max(48)
32
+ .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, 'nome de campo inválido (use letras/números/_)');
33
+
34
+ /** Campos que a Strapi cria sozinha — o manifest não pode redefinir. */
35
+ const RESERVED_ATTRS = new Set([
36
+ 'id',
37
+ 'documentId',
38
+ 'createdAt',
39
+ 'updatedAt',
40
+ 'publishedAt',
41
+ 'createdBy',
42
+ 'updatedBy',
43
+ 'locale',
44
+ 'localizations',
45
+ ]);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Atributos (subconjunto seguro do Strapi 5 — fase 1)
49
+ // Componentes e dynamic zones ficam para a fase 2.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const SCALAR_TYPES = [
53
+ 'string',
54
+ 'text',
55
+ 'richtext',
56
+ 'blocks',
57
+ 'email',
58
+ 'integer',
59
+ 'biginteger',
60
+ 'float',
61
+ 'decimal',
62
+ 'boolean',
63
+ 'date',
64
+ 'datetime',
65
+ 'time',
66
+ 'json',
67
+ ] as const;
68
+
69
+ const commonOpts = {
70
+ required: z.boolean().optional(),
71
+ unique: z.boolean().optional(),
72
+ private: z.boolean().optional(),
73
+ default: z.union([z.string(), z.number(), z.boolean()]).optional(),
74
+ /**
75
+ * Marca o campo como traduzível por locale (i18n). Opt-in: ausente ⇒ o campo
76
+ * é compartilhado entre locales (comportamento atual). Vira
77
+ * `pluginOptions.i18n.localized:true` no schema gerado. Só faz efeito se a
78
+ * própria content-type também tiver `localized:true`.
79
+ */
80
+ localized: z.boolean().optional(),
81
+ };
82
+
83
+ const scalarAttr = z.object({
84
+ type: z.enum(SCALAR_TYPES),
85
+ ...commonOpts,
86
+ });
87
+
88
+ /** uid: precisa apontar para um campo string da mesma content-type. */
89
+ const uidAttr = z.object({
90
+ type: z.literal('uid'),
91
+ targetField: attrKey.optional(),
92
+ required: z.boolean().optional(),
93
+ });
94
+
95
+ const enumAttr = z.object({
96
+ type: z.literal('enumeration'),
97
+ enum: z.array(z.string().min(1)).min(1),
98
+ ...commonOpts,
99
+ });
100
+
101
+ const mediaAttr = z.object({
102
+ type: z.literal('media'),
103
+ multiple: z.boolean().optional(),
104
+ allowedTypes: z
105
+ .array(z.enum(['images', 'videos', 'files', 'audios']))
106
+ .optional(),
107
+ required: z.boolean().optional(),
108
+ });
109
+
110
+ const RELATION_KINDS = [
111
+ 'oneToOne',
112
+ 'oneToMany',
113
+ 'manyToOne',
114
+ 'manyToMany',
115
+ ] as const;
116
+
117
+ /**
118
+ * Relação: `target` deve ser o singularName de OUTRA content-type declarada
119
+ * neste mesmo manifest (validado no refinamento global abaixo). Mantemos as
120
+ * relações internas ao manifest para garantir que nada aponte para um type
121
+ * inexistente — uma das fontes clássicas de "schema quebrado".
122
+ */
123
+ const relationAttr = z.object({
124
+ type: z.literal('relation'),
125
+ relation: z.enum(RELATION_KINDS),
126
+ target: kebab,
127
+ required: z.boolean().optional(),
128
+ });
129
+
130
+ const attribute = z.union([
131
+ scalarAttr,
132
+ uidAttr,
133
+ enumAttr,
134
+ mediaAttr,
135
+ relationAttr,
136
+ ]);
137
+
138
+ export type ManifestAttribute = z.infer<typeof attribute>;
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Content-type
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const contentType = z
145
+ .object({
146
+ singularName: kebab,
147
+ pluralName: kebab.optional(),
148
+ displayName: z.string().min(1).max(64).optional(),
149
+ kind: z.enum(['collectionType', 'singleType']).default('collectionType'),
150
+ draftAndPublish: z.boolean().default(true),
151
+ /**
152
+ * Liga a localização (i18n) na content-type. Necessário no nível da CT
153
+ * (confirmado em @strapi/i18n: `isLocalizedContentType` checa
154
+ * `pluginOptions.i18n.localized`). Os campos a traduzir devem marcar
155
+ * `localized:true` individualmente.
156
+ */
157
+ localized: z.boolean().optional(),
158
+ description: z.string().max(255).optional(),
159
+ attributes: z
160
+ .record(attrKey, attribute)
161
+ .refine((attrs) => Object.keys(attrs).length > 0, {
162
+ message: 'a content-type precisa de pelo menos 1 campo',
163
+ })
164
+ .refine(
165
+ (attrs) => Object.keys(attrs).every((k) => !RESERVED_ATTRS.has(k)),
166
+ { message: 'um campo usa nome reservado da Strapi (ex.: id, createdAt)' }
167
+ ),
168
+ /**
169
+ * Rota do frontend usada para o preview, com placeholders de campo entre
170
+ * dois-pontos. Ex.: "/produtos/:slug". O adapter de cada framework usa isso
171
+ * para montar a URL de preview no PreviewPanel.
172
+ */
173
+ preview: z
174
+ .object({
175
+ route: z
176
+ .string()
177
+ .startsWith('/', 'a rota de preview deve começar com "/"'),
178
+ })
179
+ .optional(),
180
+ })
181
+ // uid.targetField precisa existir e ser string/text
182
+ .superRefine((ct, ctx) => {
183
+ for (const [key, attr] of Object.entries(ct.attributes)) {
184
+ if (attr.type === 'uid' && attr.targetField) {
185
+ const target = ct.attributes[attr.targetField];
186
+ if (!target) {
187
+ ctx.addIssue({
188
+ code: z.ZodIssueCode.custom,
189
+ message: `uid "${key}" aponta para campo inexistente "${attr.targetField}"`,
190
+ });
191
+ } else if (!['string', 'text'].includes((target as any).type)) {
192
+ ctx.addIssue({
193
+ code: z.ZodIssueCode.custom,
194
+ message: `uid "${key}" deve apontar para um campo string/text`,
195
+ });
196
+ }
197
+ }
198
+ }
199
+ });
200
+
201
+ export type ManifestContentType = z.infer<typeof contentType>;
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Manifest raiz
205
+ // ---------------------------------------------------------------------------
206
+
207
+ export const FRAMEWORKS = ['next', 'tanstack'] as const;
208
+ export type Framework = (typeof FRAMEWORKS)[number];
209
+
210
+ export const manifestSchema = z
211
+ .object({
212
+ /** versão do formato do manifest, não do app. */
213
+ manifestVersion: z.literal(1).default(1),
214
+ name: kebab,
215
+ framework: z.enum(FRAMEWORKS),
216
+ strapiVersion: z.string().optional(),
217
+ contentTypes: z.array(contentType).min(1).max(60),
218
+ /** dados demo opcionais, semeados via Document Service após o restart. */
219
+ seed: z
220
+ .array(
221
+ z.object({
222
+ uid: z.string().optional(), // resolvido pelo provisionador
223
+ singularName: kebab,
224
+ entries: z.array(z.record(z.string(), z.any())).max(500),
225
+ })
226
+ )
227
+ .optional(),
228
+ /** nomes de env vars que o frontend espera (o adapter escreve o .env). */
229
+ env: z.array(z.string()).optional(),
230
+ })
231
+ // nenhum singularName repetido + relações apontam para types existentes
232
+ .superRefine((m, ctx) => {
233
+ const names = m.contentTypes.map((c) => c.singularName);
234
+ const seen = new Set<string>();
235
+ for (const n of names) {
236
+ if (seen.has(n)) {
237
+ ctx.addIssue({
238
+ code: z.ZodIssueCode.custom,
239
+ message: `content-type duplicada: "${n}"`,
240
+ });
241
+ }
242
+ seen.add(n);
243
+ }
244
+ const known = new Set(names);
245
+ for (const ct of m.contentTypes) {
246
+ for (const [key, attr] of Object.entries(ct.attributes)) {
247
+ if (attr.type === 'relation' && !known.has(attr.target)) {
248
+ ctx.addIssue({
249
+ code: z.ZodIssueCode.custom,
250
+ message: `relação "${ct.singularName}.${key}" aponta para "${attr.target}", que não está no manifest`,
251
+ });
252
+ }
253
+ }
254
+ }
255
+ });
256
+
257
+ export type Manifest = z.infer<typeof manifestSchema>;
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Validador público
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export type ValidationResult =
264
+ | { ok: true; data: Manifest }
265
+ | { ok: false; errors: string[] };
266
+
267
+ /**
268
+ * Valida um manifest cru (objeto JS já parseado de JSON). Retorna ou os dados
269
+ * já normalizados (defaults aplicados), ou uma lista de erros legíveis. Esta é
270
+ * a única porta de entrada: o provisionador só roda com `ok: true`.
271
+ */
272
+ export function validateManifest(raw: unknown): ValidationResult {
273
+ const parsed = manifestSchema.safeParse(raw);
274
+ if (parsed.success) return { ok: true, data: parsed.data };
275
+
276
+ const errors = parsed.error.issues.map((i) => {
277
+ const path = i.path.length ? `${i.path.join('.')}: ` : '';
278
+ return `${path}${i.message}`;
279
+ });
280
+ return { ok: false, errors };
281
+ }