ponch-mcp-server 1.0.76 → 1.0.77

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/dist/index.js CHANGED
@@ -47,14 +47,14 @@ function resolveAuth() {
47
47
  const resolvedPath = saPath.startsWith("/") ? saPath : (0, import_path.join)(process.cwd(), saPath);
48
48
  if ((0, import_fs.existsSync)(resolvedPath)) {
49
49
  return {
50
- mode: "cowork",
50
+ canSwitchTenant: true,
51
+ // service account = admin del SaaS
51
52
  tenantId: null,
52
53
  brandId: null,
53
54
  brands: [],
54
55
  userId: null,
55
56
  userName: null,
56
57
  rol: "super_admin",
57
- // service account = admin total
58
58
  serviceAccountPath: resolvedPath,
59
59
  credentialsPath: null
60
60
  };
@@ -65,7 +65,7 @@ function resolveAuth() {
65
65
  try {
66
66
  const creds = JSON.parse((0, import_fs.readFileSync)(CREDENTIALS_PATH, "utf-8"));
67
67
  return {
68
- mode: creds.rol === "super_admin" ? "cowork" : "tenant",
68
+ canSwitchTenant: creds.rol === "super_admin",
69
69
  tenantId: creds.tenantId ?? null,
70
70
  brandId: creds.brands?.[0]?.id ?? null,
71
71
  brands: creds.brands ?? [],
@@ -80,7 +80,7 @@ function resolveAuth() {
80
80
  }
81
81
  }
82
82
  return {
83
- mode: "tenant",
83
+ canSwitchTenant: false,
84
84
  tenantId: null,
85
85
  brandId: null,
86
86
  brands: [],
@@ -108,6 +108,10 @@ function readCredentials() {
108
108
  var Session = class {
109
109
  context;
110
110
  _authContext;
111
+ _clientIdentity = {
112
+ mcpClient: null,
113
+ mcpClientVersion: null
114
+ };
111
115
  constructor(auth) {
112
116
  this._authContext = auth;
113
117
  this.context = {
@@ -115,8 +119,26 @@ var Session = class {
115
119
  brandId: auth.brandId
116
120
  };
117
121
  }
118
- get mode() {
119
- return this._authContext.mode;
122
+ /**
123
+ * Captura identidad del cliente MCP. Llamado al recibir handshake
124
+ * `initialize` del SDK. Idempotente — si se llama 2 veces, segunda no
125
+ * sobrescribe (el cliente no cambia mid-session).
126
+ */
127
+ setMcpClientIdentity(name, version) {
128
+ if (this._clientIdentity.mcpClient === null) {
129
+ this._clientIdentity.mcpClient = name;
130
+ this._clientIdentity.mcpClientVersion = version;
131
+ }
132
+ }
133
+ get mcpClientIdentity() {
134
+ return this._clientIdentity;
135
+ }
136
+ /**
137
+ * true → super_admin del SaaS (puede cambiar tenant). false → tenant
138
+ * member normal. Independiente del SDK que esté corriendo el MCP.
139
+ */
140
+ get canSwitchTenant() {
141
+ return this._authContext.canSwitchTenant;
120
142
  }
121
143
  get tenantId() {
122
144
  return this.context.tenantId;
@@ -137,19 +159,27 @@ var Session = class {
137
159
  get userId() {
138
160
  return this._authContext.userId;
139
161
  }
140
- /** Rol del usuario (admin/encargado/empleado/super_admin). 211.1 HITO 6 */
162
+ /** Rol del usuario (super_admin | ROL00X del tenant). */
141
163
  get rol() {
142
164
  return this._authContext.rol;
143
165
  }
144
166
  setContext(tenantId, brandId) {
145
- if (this._authContext.mode !== "cowork") {
146
- throw new Error("set_context solo disponible en Modo Cowork");
167
+ if (!this._authContext.canSwitchTenant) {
168
+ throw new Error("set_context solo disponible para usuarios con permiso multi-tenant (super_admin).");
147
169
  }
148
170
  this.context.tenantId = tenantId;
149
171
  this.context.brandId = brandId;
150
172
  }
151
173
  /**
152
- * Actualiza el contexto despues de connect_account (Modo B)
174
+ * Revoca canSwitchTenant en runtime. Usado cuando el bootstrap detecta
175
+ * token expirado — el usuario debe re-autenticar via connect_account
176
+ * antes de poder cambiar de tenant.
177
+ */
178
+ revokeCrossTenant() {
179
+ this._authContext.canSwitchTenant = false;
180
+ }
181
+ /**
182
+ * Actualiza el contexto después de connect_account.
153
183
  */
154
184
  setTenantContext(tenantId, brandId, brands, userId, userName) {
155
185
  this.context.tenantId = tenantId;
@@ -160,16 +190,10 @@ var Session = class {
160
190
  this._authContext.userId = userId;
161
191
  this._authContext.userName = userName;
162
192
  }
163
- /**
164
- * Fuerza el modo (usado cuando credentials existen pero token expiro)
165
- */
166
- forceMode(newMode) {
167
- this._authContext.mode = newMode;
168
- }
169
193
  requireTenant() {
170
194
  if (!this.context.tenantId) {
171
195
  throw new Error(
172
- this._authContext.mode === "cowork" ? "Usa set_context para seleccionar un tenant primero" : "No conectado. Usa connect_account para conectar tu cuenta de Ponch"
196
+ this._authContext.canSwitchTenant ? "Usa set_context para seleccionar un tenant primero." : "No conectado. Usa connect_account para conectar tu cuenta de Ponch."
173
197
  );
174
198
  }
175
199
  return this.context.tenantId;
@@ -188,11 +212,11 @@ var import_app = require("firebase/app");
188
212
  var import_lite = require("firebase/firestore/lite");
189
213
  var import_auth = require("firebase/auth");
190
214
  var import_fs2 = require("fs");
191
- var mode = "none";
215
+ var sdkMode = "none";
192
216
  var adminDb;
193
217
  var clientDb;
194
218
  function initFirebaseAdmin(serviceAccountPath) {
195
- if (mode !== "none") return;
219
+ if (sdkMode !== "none") return;
196
220
  const serviceAccount = JSON.parse(
197
221
  (0, import_fs2.readFileSync)(serviceAccountPath, "utf-8")
198
222
  );
@@ -200,10 +224,10 @@ function initFirebaseAdmin(serviceAccountPath) {
200
224
  credential: import_firebase_admin.default.credential.cert(serviceAccount)
201
225
  });
202
226
  adminDb = import_firebase_admin.default.firestore();
203
- mode = "admin";
227
+ sdkMode = "admin";
204
228
  }
205
229
  async function initFirebaseClient() {
206
- if (mode !== "none") return true;
230
+ if (sdkMode !== "none") return true;
207
231
  const creds = readCredentials();
208
232
  if (!creds) {
209
233
  console.error("[firestore] No hay credentials.json");
@@ -221,7 +245,7 @@ async function initFirebaseClient() {
221
245
  if (newCustomToken) {
222
246
  await (0, import_auth.signInWithCustomToken)(auth, newCustomToken);
223
247
  clientDb = (0, import_lite.getFirestore)(app);
224
- mode = "client";
248
+ sdkMode = "client";
225
249
  saveCredentials({ ...creds, customToken: newCustomToken });
226
250
  console.error("[firestore] Firebase Client re-autenticado con refreshToken");
227
251
  return true;
@@ -241,7 +265,7 @@ async function initFirebaseClient() {
241
265
  console.error("[firestore] refreshToken guardado para futuras sesiones");
242
266
  }
243
267
  clientDb = (0, import_lite.getFirestore)(app);
244
- mode = "client";
268
+ sdkMode = "client";
245
269
  console.error("[firestore] Firebase Client autenticado con customToken");
246
270
  return true;
247
271
  } catch (err) {
@@ -306,27 +330,27 @@ async function reauthClient(customToken) {
306
330
  console.error("[firestore] refreshToken guardado");
307
331
  }
308
332
  clientDb = (0, import_lite.getFirestore)(app);
309
- mode = "client";
333
+ sdkMode = "client";
310
334
  return true;
311
335
  } catch (err) {
312
336
  console.error("[firestore] Error re-autenticando:", err);
313
337
  return false;
314
338
  }
315
339
  }
316
- function getMode() {
317
- return mode;
340
+ function getSdkMode() {
341
+ return sdkMode;
318
342
  }
319
343
  function getAdminDb() {
320
- if (mode !== "admin") {
321
- throw new Error("getAdminDb: modo admin requerido (actual: " + mode + ")");
344
+ if (sdkMode !== "admin") {
345
+ throw new Error("getAdminDb: SDK admin requerido (actual: " + sdkMode + ")");
322
346
  }
323
347
  return adminDb;
324
348
  }
325
349
  async function queryByTenant(session, collectionName, filters = [], options = {}) {
326
350
  const tenantId = session.requireTenant();
327
- if (mode === "admin") {
351
+ if (sdkMode === "admin") {
328
352
  return queryByTenantAdmin(tenantId, collectionName, filters, options);
329
- } else if (mode === "client") {
353
+ } else if (sdkMode === "client") {
330
354
  return queryByTenantClient(tenantId, collectionName, filters, options);
331
355
  }
332
356
  throw new Error("Firebase no inicializado");
@@ -362,11 +386,11 @@ async function queryByTenantClient(tenantId, collectionName, filters, options) {
362
386
  return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
363
387
  }
364
388
  async function readDoc(collectionName, docId) {
365
- if (mode === "admin") {
389
+ if (sdkMode === "admin") {
366
390
  const doc = await adminDb.collection(collectionName).doc(docId).get();
367
391
  if (!doc.exists) return null;
368
392
  return { id: doc.id, ...doc.data() };
369
- } else if (mode === "client") {
393
+ } else if (sdkMode === "client") {
370
394
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
371
395
  const doc = await (0, import_lite.getDoc)(ref);
372
396
  if (!doc.exists()) return null;
@@ -374,10 +398,21 @@ async function readDoc(collectionName, docId) {
374
398
  }
375
399
  throw new Error("Firebase no inicializado");
376
400
  }
401
+ async function listCollection(path) {
402
+ if (sdkMode === "admin") {
403
+ const snap = await adminDb.collection(path).get();
404
+ return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
405
+ } else if (sdkMode === "client") {
406
+ const ref = (0, import_lite.collection)(clientDb, path);
407
+ const snap = await (0, import_lite.getDocs)(ref);
408
+ return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
409
+ }
410
+ throw new Error("Firebase no inicializado");
411
+ }
377
412
  async function writeDoc(collectionName, docId, data) {
378
- if (mode === "admin") {
413
+ if (sdkMode === "admin") {
379
414
  await adminDb.collection(collectionName).doc(docId).set(data, { merge: true });
380
- } else if (mode === "client") {
415
+ } else if (sdkMode === "client") {
381
416
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
382
417
  await (0, import_lite.setDoc)(ref, data, { merge: true });
383
418
  } else {
@@ -385,9 +420,9 @@ async function writeDoc(collectionName, docId, data) {
385
420
  }
386
421
  }
387
422
  async function updateDoc(collectionName, docId, data) {
388
- if (mode === "admin") {
423
+ if (sdkMode === "admin") {
389
424
  await adminDb.collection(collectionName).doc(docId).update(data);
390
- } else if (mode === "client") {
425
+ } else if (sdkMode === "client") {
391
426
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
392
427
  await (0, import_lite.updateDoc)(ref, data);
393
428
  } else {
@@ -395,7 +430,7 @@ async function updateDoc(collectionName, docId, data) {
395
430
  }
396
431
  }
397
432
  function serverTimestamp() {
398
- if (mode === "admin") {
433
+ if (sdkMode === "admin") {
399
434
  return import_firebase_admin.default.firestore.FieldValue.serverTimestamp();
400
435
  } else {
401
436
  return (0, import_lite.serverTimestamp)();
@@ -407,10 +442,10 @@ var import_zod = require("zod");
407
442
  var import_functions = require("firebase/functions");
408
443
  var import_app2 = require("firebase/app");
409
444
  function registerContextTools(server, session) {
410
- if (session.mode === "cowork") {
445
+ if (session.canSwitchTenant) {
411
446
  server.tool(
412
447
  "set_context",
413
- "Establece el tenant y brand para esta sesion. Solo disponible en Modo Cowork (admin).",
448
+ "Establece el tenant y brand para esta sesion. Solo disponible para super_admin.",
414
449
  {
415
450
  tenantId: import_zod.z.string().describe('ID del tenant (ej: "atteyo")'),
416
451
  brandId: import_zod.z.string().describe('ID de la brand (ej: "ponch")')
@@ -418,8 +453,8 @@ function registerContextTools(server, session) {
418
453
  async ({ tenantId, brandId }) => {
419
454
  const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
420
455
  if (!brand) {
421
- const brandsSnap = await getAdminDb().collection("tenants").doc(tenantId).collection("marketing_config").get();
422
- if (brandsSnap.empty) {
456
+ const brands = await listCollection(`tenants/${tenantId}/marketing_config`);
457
+ if (brands.length === 0) {
423
458
  return {
424
459
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Tenant "${tenantId}" no tiene marketing_config` }) }]
425
460
  };
@@ -430,7 +465,7 @@ function registerContextTools(server, session) {
430
465
  text: JSON.stringify({
431
466
  ok: false,
432
467
  error: `Brand "${brandId}" no existe en tenant "${tenantId}"`,
433
- brandsDisponibles: brandsSnap.docs.map((d) => d.id)
468
+ brandsDisponibles: brands.map((b) => b.id)
434
469
  })
435
470
  }]
436
471
  };
@@ -2120,7 +2155,7 @@ var TenantStatusFieldsSchema = import_zod14.z.object({
2120
2155
  cancellation: CancellationInfoSchema
2121
2156
  }).passthrough();
2122
2157
  var AuditActorSchema = import_zod15.z.object({
2123
- type: import_zod15.z.enum(["user", "martin", "system", "cf_scheduled", "webhook"]),
2158
+ type: import_zod15.z.enum(["user", "martin", "mcp_client", "system", "cf_scheduled", "webhook"]),
2124
2159
  uid: import_zod15.z.string().nullable(),
2125
2160
  nombre: import_zod15.z.string(),
2126
2161
  metadata: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.any()).optional()
@@ -2734,6 +2769,9 @@ var import_firebase_admin3 = require("firebase-admin");
2734
2769
  var import_zod27 = require("zod");
2735
2770
  var import_zod28 = require("zod");
2736
2771
  var import_zod29 = require("zod");
2772
+ var import_fs3 = require("fs");
2773
+ var import_path2 = require("path");
2774
+ var import_url = require("url");
2737
2775
  var import_firebase_admin4 = require("firebase-admin");
2738
2776
  var import_firebase_admin5 = require("firebase-admin");
2739
2777
  var import_firebase_admin6 = require("firebase-admin");
@@ -2753,6 +2791,7 @@ var import_zod34 = require("zod");
2753
2791
  var import_firestore7 = require("firebase-admin/firestore");
2754
2792
  var import_zod35 = require("zod");
2755
2793
  var import_firestore8 = require("firebase-admin/firestore");
2794
+ var import_meta = {};
2756
2795
  var RULE_NEGATIVES = {
2757
2796
  allowFaces: "no people, no faces, no hands",
2758
2797
  allowProductTransform: "no distorted products, no warped objects",
@@ -3013,6 +3052,74 @@ var planWriter = {
3013
3052
  save,
3014
3053
  updateField
3015
3054
  };
3055
+ var _localeCache = {};
3056
+ function _resolveLocaleFile(locale) {
3057
+ let baseDir;
3058
+ try {
3059
+ baseDir = typeof __dirname !== "undefined" ? __dirname : (0, import_path2.dirname)((0, import_url.fileURLToPath)(import_meta.url));
3060
+ } catch {
3061
+ baseDir = process.cwd();
3062
+ }
3063
+ const candidates = [
3064
+ (0, import_path2.join)(baseDir, "..", "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
3065
+ (0, import_path2.join)(baseDir, "..", "..", "..", "src", "i18n", "locales", `${locale}.json`)
3066
+ ];
3067
+ for (const candidate of candidates) {
3068
+ try {
3069
+ (0, import_fs3.readFileSync)(candidate, "utf-8");
3070
+ return candidate;
3071
+ } catch {
3072
+ continue;
3073
+ }
3074
+ }
3075
+ throw new Error(
3076
+ `getMessage: locale file '${locale}.json' not found. Tried:
3077
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
3078
+ );
3079
+ }
3080
+ function _loadLocale(locale) {
3081
+ if (_localeCache[locale]) return _localeCache[locale];
3082
+ const path2 = _resolveLocaleFile(locale);
3083
+ const raw = (0, import_fs3.readFileSync)(path2, "utf-8");
3084
+ const data = JSON.parse(raw);
3085
+ _localeCache[locale] = data;
3086
+ return data;
3087
+ }
3088
+ function _resolvePath(obj, path2) {
3089
+ const parts = path2.split(".");
3090
+ let current = obj;
3091
+ for (const part of parts) {
3092
+ if (current === null || typeof current !== "object") return null;
3093
+ current = current[part];
3094
+ }
3095
+ return typeof current === "string" ? current : null;
3096
+ }
3097
+ function _interpolate(template, vars, context) {
3098
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
3099
+ if (!vars || !(key in vars)) {
3100
+ throw new Error(
3101
+ `getMessage: variable '${key}' in template '${context.path}' (${context.locale}) not provided in vars. Pass { ${key}: '...' } when calling getMessage.`
3102
+ );
3103
+ }
3104
+ return vars[key];
3105
+ });
3106
+ }
3107
+ var SUPPORTED_LOCALES = ["es", "en"];
3108
+ function getMessage(path2, locale, vars) {
3109
+ if (!SUPPORTED_LOCALES.includes(locale)) {
3110
+ throw new Error(
3111
+ `getMessage: locale '${locale}' not supported. Active locales: ${SUPPORTED_LOCALES.join(", ")}.`
3112
+ );
3113
+ }
3114
+ const data = _loadLocale(locale);
3115
+ const template = _resolvePath(data, path2);
3116
+ if (template === null) {
3117
+ throw new Error(
3118
+ `getMessage: path '${path2}' not found in locale '${locale}'. Add it to src/i18n/locales/${locale}.json.`
3119
+ );
3120
+ }
3121
+ return _interpolate(template, vars, { path: path2, locale });
3122
+ }
3016
3123
  var DisabledReasonCodeSchema = import_zod29.z.enum([
3017
3124
  "paywall",
3018
3125
  // Acción requiere upgrade de plan
@@ -3038,44 +3145,8 @@ var DisabledOutputSchema = import_zod29.z.object({
3038
3145
  */
3039
3146
  detail: import_zod29.z.record(import_zod29.z.string(), import_zod29.z.string()).optional()
3040
3147
  });
3041
- var TEMPLATES = {
3042
- es: {
3043
- paywall: "Esta acci\xF3n requiere actualizar tu plan.",
3044
- oauth_missing: "Necesitas conectar {service} primero.",
3045
- data_missing: "Falta informaci\xF3n: {detail}",
3046
- plan_not_includes: "Tu plan no incluye {feature}",
3047
- feature_disabled: "Esta funci\xF3n est\xE1 deshabilitada para tu cuenta.",
3048
- other: "{detail}"
3049
- },
3050
- en: {
3051
- paywall: "This action requires upgrading your plan.",
3052
- oauth_missing: "You need to connect {service} first.",
3053
- data_missing: "Missing information: {detail}",
3054
- plan_not_includes: "Your plan doesn't include {feature}",
3055
- feature_disabled: "This feature is disabled for your account.",
3056
- other: "{detail}"
3057
- }
3058
- };
3059
3148
  function getDisabledMessage(code, locale, vars) {
3060
- if (!(locale in TEMPLATES)) {
3061
- throw new Error(
3062
- `Locale '${locale}' no soportado en disabledMessages. Locales activos: ${Object.keys(TEMPLATES).join(", ")}.`
3063
- );
3064
- }
3065
- const template = TEMPLATES[locale][code];
3066
- if (!template) {
3067
- throw new Error(
3068
- `Disabled code '${code}' no tiene template definido para locale '${locale}'.`
3069
- );
3070
- }
3071
- return template.replace(/\{(\w+)\}/g, (_match, key) => {
3072
- if (!vars || !(key in vars)) {
3073
- throw new Error(
3074
- `getDisabledMessage: variable '${key}' del template '${code}' (${locale}) no provista en vars. Helper debe pasar { ${key}: '...' } en detail.`
3075
- );
3076
- }
3077
- return vars[key];
3078
- });
3149
+ return getMessage(`marketing.disabled.${code}`, locale, vars);
3079
3150
  }
3080
3151
  var SideEffectEnum = import_zod28.z.enum([
3081
3152
  "reads_firestore",
@@ -3107,6 +3178,10 @@ var ExtractTargetPathSchema = import_zod28.z.custom((val) => typeof val === "fun
3107
3178
  var ExtractChangesSchema = import_zod28.z.custom((val) => typeof val === "function", {
3108
3179
  message: "extractChanges debe ser funci\xF3n (input, output) => { before, after }"
3109
3180
  });
3181
+ var InputPredicateSchema = import_zod28.z.custom(
3182
+ (val) => typeof val === "function",
3183
+ { message: "predicado debe ser funci\xF3n (input) => boolean" }
3184
+ );
3110
3185
  var MartinContractSchema = import_zod28.z.object({
3111
3186
  // Schema versioning (Dim 4 backbone)
3112
3187
  schemaVersion: import_zod28.z.literal(1).default(1),
@@ -3122,6 +3197,16 @@ var MartinContractSchema = import_zod28.z.object({
3122
3197
  destructive: import_zod28.z.boolean(),
3123
3198
  affectsPublication: import_zod28.z.boolean(),
3124
3199
  affectsExternal: import_zod28.z.boolean(),
3200
+ /**
3201
+ * Override runtime del flag `destructive`. Si el predicado retorna
3202
+ * true para un input específico, el wrapper trata la invocación
3203
+ * como destructiva aun si `destructive: false`. Útil cuando una
3204
+ * misma tool tiene 2 modos según args (ej. update con
3205
+ * accionContenidoExistente='descartar').
3206
+ */
3207
+ isDestructiveForInput: InputPredicateSchema.optional(),
3208
+ /** Mismo patrón para affectsPublication (override runtime). */
3209
+ isPublicationForInput: InputPredicateSchema.optional(),
3125
3210
  // Presentación al usuario (i18n)
3126
3211
  martinSummaryTemplate: SummaryTemplateSchema,
3127
3212
  martinConfirmationTemplate: ConfirmationTemplateSchema.optional(),
@@ -3262,7 +3347,7 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3262
3347
  ]);
3263
3348
  var rawContract = {
3264
3349
  name: "save_marketing_plan",
3265
- description: "Guarda el plan de marketing de una brand. Si el plan incluye blogStrategy, la extrae al nivel brand (no dentro de plan).",
3350
+ description: "Save a marketing plan for a brand. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
3266
3351
  paramsSchema: ParamsSchema,
3267
3352
  outputSchema: OutputSchema,
3268
3353
  // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
@@ -3566,7 +3651,7 @@ var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3566
3651
  ]);
3567
3652
  var rawContract2 = {
3568
3653
  name: "add_calendar_slot",
3569
- description: "Agrega un slot nuevo al calendario editorial. NO modifica slots existentes \u2014 para eso usa update_calendar_slot.",
3654
+ description: "Add a new slot to the editorial calendar. Does NOT modify existing slots \u2014 use update_calendar_slot for that.",
3570
3655
  paramsSchema: ParamsSchema2,
3571
3656
  outputSchema: OutputSchema2,
3572
3657
  requiresConfirmation: false,
@@ -3636,7 +3721,7 @@ async function calendarSlotUpdater(input) {
3636
3721
  if (slotIndex >= items.length) {
3637
3722
  return {
3638
3723
  ok: false,
3639
- error: `slotIndex ${slotIndex} no existe (semana ${semana} tiene ${items.length} slots). Para agregar un slot nuevo usa add_calendar_slot.`,
3724
+ error: `slotIndex=${slotIndex} >= items.length=${items.length} for week=${semana}`,
3640
3725
  code: "SLOT_NOT_FOUND"
3641
3726
  };
3642
3727
  }
@@ -3648,14 +3733,9 @@ async function calendarSlotUpdater(input) {
3648
3733
  if (oldContenidoRef && tocaSemantica && !accionContenidoExistente) {
3649
3734
  return {
3650
3735
  ok: false,
3651
- error: `El slot tiene contenidoRef previo "${oldContenidoRef}" y los cambios tocan campos semanticos (${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(", ")}). El sistema NO decide automaticamente que hacer con el contenido viejo. Pregunta al tenant y vuelve a llamar con accionContenidoExistente en una de las 4 opciones.`,
3736
+ error: `slot has contenidoRef=${oldContenidoRef} and cambios touch semantic fields=[${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(",")}]; require accionContenidoExistente`,
3652
3737
  code: "ACCION_CONTENIDO_EXISTENTE_REQUIRED",
3653
- opciones: [
3654
- `'descartar' \u2014 marcar "${oldContenidoRef}" como descartado y aplicar cambios en este slot`,
3655
- `'mover:semana:N:slot:M' \u2014 mover "${oldContenidoRef}" al slot destino N/M (target debe estar vacio) y aplicar cambios aqui`,
3656
- `'nuevo_slot' \u2014 NO tocar este slot ni "${oldContenidoRef}". Crear un slot nuevo el mismo dia con los cambios (util para multiples publicaciones/dia \u2014 ej: almuerzo + cena GBP)`,
3657
- `'mantener' \u2014 mantener "${oldContenidoRef}" aqui y aplicar cambios (caso typo fix, el contenido viejo sigue valido para el nuevo keyword/tema)`
3658
- ]
3738
+ opciones: ["descartar", "mover", "nuevo_slot", "mantener"]
3659
3739
  };
3660
3740
  }
3661
3741
  let contenidosADescartar = [];
@@ -3675,7 +3755,7 @@ async function calendarSlotUpdater(input) {
3675
3755
  if (!m) {
3676
3756
  return {
3677
3757
  ok: false,
3678
- error: `accionContenidoExistente "${accionContenidoExistente}" invalido. Formato: mover:semana:N:slot:M`,
3758
+ error: `accionContenidoExistente="${accionContenidoExistente}" invalid format (expected: mover:semana:N:slot:M)`,
3679
3759
  code: "MOVE_TARGET_INVALID"
3680
3760
  };
3681
3761
  }
@@ -3684,7 +3764,7 @@ async function calendarSlotUpdater(input) {
3684
3764
  if (targetSemanaNum === semana && targetSlotIdx === slotIndex) {
3685
3765
  return {
3686
3766
  ok: false,
3687
- error: "mover al mismo slot origen no tiene sentido. Usa 'mantener' si quieres conservar el contenido aqui.",
3767
+ error: "move target same as origin (semantic noop)",
3688
3768
  code: "MOVE_TARGET_INVALID"
3689
3769
  };
3690
3770
  }
@@ -3692,7 +3772,7 @@ async function calendarSlotUpdater(input) {
3692
3772
  if (!targetSemana) {
3693
3773
  return {
3694
3774
  ok: false,
3695
- error: `Target semana ${targetSemanaNum} no existe en el calendario`,
3775
+ error: `target semana=${targetSemanaNum} does not exist`,
3696
3776
  code: "MOVE_TARGET_INVALID"
3697
3777
  };
3698
3778
  }
@@ -3700,14 +3780,14 @@ async function calendarSlotUpdater(input) {
3700
3780
  if (!targetItems[targetSlotIdx]) {
3701
3781
  return {
3702
3782
  ok: false,
3703
- error: `Target slot ${targetSlotIdx} no existe en semana ${targetSemanaNum}`,
3783
+ error: `target slot=${targetSlotIdx} does not exist in semana=${targetSemanaNum}`,
3704
3784
  code: "MOVE_TARGET_INVALID"
3705
3785
  };
3706
3786
  }
3707
3787
  if (targetItems[targetSlotIdx].contenidoRef) {
3708
3788
  return {
3709
3789
  ok: false,
3710
- error: `Target slot ya tiene contenidoRef "${targetItems[targetSlotIdx].contenidoRef}". No se sobrescribe para evitar perder contenido ajeno. Elige otro slot o usa 'descartar'.`,
3790
+ error: `target slot already has contenidoRef=${targetItems[targetSlotIdx].contenidoRef}`,
3711
3791
  code: "MOVE_TARGET_OCCUPIED"
3712
3792
  };
3713
3793
  }
@@ -3889,18 +3969,25 @@ var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3889
3969
  ]);
3890
3970
  var rawContract3 = {
3891
3971
  name: "update_calendar_slot",
3892
- description: "MODIFICA un slot existente del calendario editorial. NO agrega slots nuevos \u2014 para eso usa add_calendar_slot. Si el slot ten\xEDa contenidoRef previo y los cambios tocan campos sem\xE1nticos (keyword/tema/plataforma/tipo), exige accionContenidoExistente con 4 opciones: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
3972
+ description: "MODIFY an existing slot in the editorial calendar. Does NOT add new slots \u2014 use add_calendar_slot for that. If the slot already had a contenidoRef and the changes touch semantic fields (keyword/tema/plataforma/tipo), requires accionContenidoExistente with 4 options: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
3893
3973
  paramsSchema: ParamsSchema3,
3894
3974
  outputSchema: OutputSchema3,
3975
+ // Por default, modificar un slot es reversible (cambias campos cosméticos).
3976
+ // PERO si accionContenidoExistente='descartar', el helper marca el
3977
+ // contenido vinculado como descartado — eso SÍ es destructivo. El predicado
3978
+ // runtime `isDestructiveForInput` lo detecta y obliga al wrapper a pedir
3979
+ // confirmación en ese caso específico.
3895
3980
  requiresConfirmation: false,
3896
3981
  destructive: false,
3897
- // Mutación, pero reversible (puede deshacerse cambiando el slot).
3982
+ isDestructiveForInput: (input) => input.accionContenidoExistente === "descartar",
3898
3983
  affectsPublication: false,
3899
3984
  affectsExternal: false,
3900
3985
  martinSummaryTemplate: (input, output, locale) => {
3901
3986
  if (!output.ok) {
3902
- if (locale === "en") return `I couldn't update the slot: ${output.error}`;
3903
- return `No pude actualizar el slot: ${output.error}`;
3987
+ if (output.code) {
3988
+ return getMessage(`marketing.errors.${output.code}`, locale);
3989
+ }
3990
+ return getMessage("marketing.safeError.generic", locale);
3904
3991
  }
3905
3992
  const verb = {
3906
3993
  updated: locale === "en" ? "updated" : "actualic\xE9",
@@ -3912,6 +3999,15 @@ var rawContract3 = {
3912
3999
  }
3913
4000
  return `${verb[0].toUpperCase() + verb.slice(1)} el slot en la semana ${input.semana} (${input.mes}).`;
3914
4001
  },
4002
+ // Confirmation message when isDestructiveForInput returns true
4003
+ // (accionContenidoExistente === 'descartar'). Only invoked if the wrapper
4004
+ // detects the destructive runtime case and asks the user for OK.
4005
+ martinConfirmationTemplate: (input, locale) => {
4006
+ if (locale === "en") {
4007
+ return `Are you sure? This will discard the existing content linked to slot ${input.slotIndex} in week ${input.semana} (${input.mes}). The content will be marked as discarded and cannot be easily recovered.`;
4008
+ }
4009
+ return `\xBFConfirmas? Esto descarta el contenido existente del slot ${input.slotIndex} en la semana ${input.semana} (${input.mes}). El contenido queda marcado como descartado y no se recupera f\xE1cilmente.`;
4010
+ },
3915
4011
  auditAction: "marketing.calendario.slot.actualizar",
3916
4012
  extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${input.slotIndex}`,
3917
4013
  extractChanges: (input, output) => ({
@@ -3968,7 +4064,7 @@ var OutputSchema4 = import_zod32.z.object({
3968
4064
  });
3969
4065
  var rawContract4 = {
3970
4066
  name: "get_calendar",
3971
- description: "Lee el calendario editorial del mes para una brand. Retorna semanas con items planificados por plataforma.",
4067
+ description: "Read the editorial calendar for a given brand and month. Returns weeks with planned items per platform.",
3972
4068
  paramsSchema: ParamsSchema4,
3973
4069
  outputSchema: OutputSchema4,
3974
4070
  requiresConfirmation: false,
@@ -4304,7 +4400,7 @@ var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
4304
4400
  ]);
4305
4401
  var rawContract5 = {
4306
4402
  name: "generate_brand_brief",
4307
- description: "Prepara los datos del negocio (Shopify, SEO, GBP, site_content scrape, brand config, ubicaciones del tenant) para que el sistema genere un Brand Brief pre-llenado. NO escribe el brief \u2014 s\xF3lo arma el payload.",
4403
+ description: "Aggregate business data (Shopify, SEO, GBP, scraped site_content, brand config, tenant locations) into a payload to generate a pre-filled Brand Brief. Read-only \u2014 does NOT write the brief; the caller saves it via save_brand_brief.",
4308
4404
  paramsSchema: ParamsSchema5,
4309
4405
  outputSchema: OutputSchema5,
4310
4406
  // Lectura pura, sin side effects de escritura ni publicación.
@@ -5060,7 +5156,7 @@ function diversify(items, jaccardThreshold = 0.6) {
5060
5156
  }
5061
5157
  async function contentFinder(input) {
5062
5158
  const { db, tenantId, brandId, contexto, fecha, include, limit, diversidad, deps } = input;
5063
- const mode2 = input.mode ?? "text";
5159
+ const mode = input.mode ?? "text";
5064
5160
  const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
5065
5161
  const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
5066
5162
  const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
@@ -5091,7 +5187,7 @@ async function contentFinder(input) {
5091
5187
  queryEmbedding: queryEmbedding ?? void 0,
5092
5188
  queryText
5093
5189
  };
5094
- if (mode2 === "text") {
5190
+ if (mode === "text") {
5095
5191
  return safeSearch(true, {
5096
5192
  ...baseParams,
5097
5193
  limit: lim,
@@ -6320,6 +6416,90 @@ async function checkQuota(tenantId, quotaName) {
6320
6416
  async function getCurrentUsage(_tenantId, _quotaName) {
6321
6417
  return 0;
6322
6418
  }
6419
+ var NIVELES_CANONICOS = [
6420
+ "ninguno",
6421
+ "ver",
6422
+ "editar",
6423
+ "completo"
6424
+ ];
6425
+ var CACHE_TTL_MS = 60 * 1e3;
6426
+ var _rolesCache = /* @__PURE__ */ new Map();
6427
+ var _userCache = /* @__PURE__ */ new Map();
6428
+ function _getCached(map, key) {
6429
+ const entry = map.get(key);
6430
+ if (!entry) return void 0;
6431
+ if (Date.now() - entry.t > CACHE_TTL_MS) {
6432
+ map.delete(key);
6433
+ return void 0;
6434
+ }
6435
+ return entry.data;
6436
+ }
6437
+ function _setCached(map, key, data) {
6438
+ map.set(key, { t: Date.now(), data });
6439
+ }
6440
+ async function _readRolesDoc(db, tenantId) {
6441
+ const key = `roles:${tenantId}`;
6442
+ const cached = _getCached(_rolesCache, key);
6443
+ if (cached !== void 0) return cached;
6444
+ const ref = db.collection("configuracion").doc(`${tenantId}_roles`);
6445
+ const snap = await ref.get();
6446
+ const data = snap.exists ? snap.data() : null;
6447
+ _setCached(_rolesCache, key, data);
6448
+ return data;
6449
+ }
6450
+ async function _readUserDoc(db, uid) {
6451
+ const key = `user:${uid}`;
6452
+ const cached = _getCached(_userCache, key);
6453
+ if (cached !== void 0) return cached;
6454
+ const ref = db.collection("usuarios").doc(uid);
6455
+ const snap = await ref.get();
6456
+ const data = snap.exists ? snap.data() : null;
6457
+ _setCached(_userCache, key, data);
6458
+ return data;
6459
+ }
6460
+ async function getUserContext({
6461
+ db,
6462
+ tenantId,
6463
+ uid
6464
+ }) {
6465
+ const userDoc = await _readUserDoc(db, uid);
6466
+ if (!userDoc) {
6467
+ throw new Error(`userContext: usuario ${uid} no encontrado.`);
6468
+ }
6469
+ const rolId = userDoc.rol ?? "empleado";
6470
+ const rolNombre = rolId === "super_admin" ? "Super Admin" : await _resolveRolNombre(db, tenantId, rolId);
6471
+ return {
6472
+ uid,
6473
+ nombre: userDoc.nombre ?? userDoc.email ?? "Usuario",
6474
+ email: userDoc.email ?? null,
6475
+ rolId,
6476
+ rolNombre,
6477
+ idiomaPreferido: userDoc.idiomaPreferido ?? "es"
6478
+ };
6479
+ }
6480
+ async function _resolveRolNombre(db, tenantId, rolId) {
6481
+ const rolesData = await _readRolesDoc(db, tenantId);
6482
+ if (!rolesData) return null;
6483
+ const rolData = rolesData[rolId];
6484
+ return rolData?.nombre ?? null;
6485
+ }
6486
+ async function getUserPermissionLevel({
6487
+ db,
6488
+ tenantId,
6489
+ userRol,
6490
+ modulo
6491
+ }) {
6492
+ if (userRol === "super_admin") return "completo";
6493
+ if (!userRol) return "ninguno";
6494
+ const rolesData = await _readRolesDoc(db, tenantId);
6495
+ if (!rolesData) return "ninguno";
6496
+ const rolData = rolesData[userRol];
6497
+ if (!rolData || rolData.activo === false) return "ninguno";
6498
+ const permisos = rolData.permisosPorModulo || {};
6499
+ const nivel = permisos[modulo];
6500
+ if (!nivel || !NIVELES_CANONICOS.includes(nivel)) return "ninguno";
6501
+ return nivel;
6502
+ }
6323
6503
  async function resolveTenantIdioma(tenantId) {
6324
6504
  const db = (0, import_firestore5.getFirestore)();
6325
6505
  const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
@@ -6335,72 +6515,44 @@ async function resolveTenantIdioma(tenantId) {
6335
6515
  }
6336
6516
  return idioma;
6337
6517
  }
6338
- var MESSAGES = {
6339
- es: {
6340
- generic: "Tuve un problema. Intent\xE9moslo de nuevo en un momento.",
6341
- quota_exceeded: "Alcanzaste el l\xEDmite mensual.",
6342
- not_found: "No lo encontr\xE9. \xBFPodr\xEDas verificar el dato?",
6343
- permission: "No tengo permiso para hacer eso desde tu cuenta.",
6344
- timeout: "Esto est\xE1 tardando m\xE1s de lo normal. \xBFLo intento de nuevo?"
6345
- },
6346
- en: {
6347
- generic: "I had a problem. Let's try again in a moment.",
6348
- quota_exceeded: "You reached the monthly limit.",
6349
- not_found: "I couldn't find it. Could you verify the data?",
6350
- permission: "I don't have permission to do that from your account.",
6351
- timeout: "This is taking longer than usual. Should I try again?"
6352
- }
6353
- };
6354
6518
  function martinSafeError(err, locale) {
6355
- if (!(locale in MESSAGES)) {
6356
- throw new Error(
6357
- `Locale '${locale}' no soportado en martinSafeError. Locales activos: ${Object.keys(MESSAGES).join(", ")}. Agregar traducciones antes de usar.`
6358
- );
6359
- }
6360
- const msgs = MESSAGES[locale];
6361
6519
  const e = err;
6362
6520
  const message = e?.message ?? "";
6363
6521
  const code = e?.code ?? "";
6364
- if (/quota|límite|limit/i.test(message)) return msgs.quota_exceeded;
6365
- if (/not.found|no.encontrado|no existe/i.test(message)) return msgs.not_found;
6366
- if (/permission|permiso/i.test(message)) return msgs.permission;
6367
- if (code === "deadline-exceeded" || /timeout/i.test(message)) return msgs.timeout;
6368
- return msgs.generic;
6369
- }
6370
- var MESSAGES2 = {
6371
- es: {
6372
- denied_role: "No tengo permiso para hacer eso desde tu cuenta. P\xEDdelo al encargado o admin que te autorice.",
6373
- input_invalido: "Falta algo o est\xE1 mal. \xBFLo intentamos de nuevo?",
6374
- confirm_default: "\xBFConfirmas?"
6375
- },
6376
- en: {
6377
- denied_role: "I don't have permission to do that from your account. Ask the manager or admin to authorize it.",
6378
- input_invalido: "Something is missing or off. Could you try again?",
6379
- confirm_default: "Are you sure?"
6380
- }
6381
- };
6522
+ let key = "generic";
6523
+ if (/quota|límite|limit/i.test(message)) key = "quota_exceeded";
6524
+ else if (/not.found|no.encontrado|no existe/i.test(message)) key = "not_found";
6525
+ else if (/permission|permiso/i.test(message)) key = "permission";
6526
+ else if (code === "deadline-exceeded" || /timeout/i.test(message)) key = "timeout";
6527
+ return getMessage(`marketing.safeError.${key}`, locale);
6528
+ }
6382
6529
  function getWrapperMessage(key, locale) {
6383
- if (!(locale in MESSAGES2)) {
6384
- throw new Error(
6385
- `Locale '${locale}' no soportado en getWrapperMessage. Locales activos: ${Object.keys(MESSAGES2).join(", ")}. Agregar traducciones antes de usar.`
6386
- );
6387
- }
6388
- const msg = MESSAGES2[locale][key];
6389
- if (!msg) {
6390
- throw new Error(
6391
- `Mensaje '${key}' no definido en wrapperMessages para locale '${locale}'.`
6392
- );
6393
- }
6394
- return msg;
6530
+ return getMessage(`marketing.wrapper.${key}`, locale);
6395
6531
  }
6396
6532
  var NIVELES_ORDER = ["ninguno", "ver", "editar", "completo"];
6397
- function nivelAlcanza(nivel, accion) {
6533
+ function nivelAlcanza2(nivel, accion) {
6398
6534
  return NIVELES_ORDER.indexOf(nivel) >= NIVELES_ORDER.indexOf(accion);
6399
6535
  }
6400
6536
  function wrapWithContract(contract, helper, options = {}) {
6401
6537
  return async function wrappedTool(input, ctx) {
6402
6538
  const startMs = Date.now();
6403
6539
  const locale = ctx.user.idiomaPreferido;
6540
+ const actorType = ctx.user.clientType === "martin" ? "martin" : "mcp_client";
6541
+ const buildActor = () => {
6542
+ const base = {
6543
+ type: actorType,
6544
+ uid: ctx.user.uid,
6545
+ nombre: ctx.user.nombre
6546
+ };
6547
+ const cm = ctx.user.clientMetadata;
6548
+ if (cm && (cm.mcpClient || cm.mcpClientVersion)) {
6549
+ base.metadata = {
6550
+ ...cm.mcpClient ? { mcpClient: cm.mcpClient } : {},
6551
+ ...cm.mcpClientVersion ? { mcpClientVersion: cm.mcpClientVersion } : {}
6552
+ };
6553
+ }
6554
+ return base;
6555
+ };
6404
6556
  let accesoOk = false;
6405
6557
  let reqDesc = "";
6406
6558
  switch (contract.permissionScope) {
@@ -6425,7 +6577,7 @@ function wrapWithContract(contract, helper, options = {}) {
6425
6577
  userRol: ctx.user.rol,
6426
6578
  modulo: contract.permissionKey
6427
6579
  });
6428
- accesoOk = nivelAlcanza(nivel, contract.permissionAction);
6580
+ accesoOk = nivelAlcanza2(nivel, contract.permissionAction);
6429
6581
  break;
6430
6582
  }
6431
6583
  case "self": {
@@ -6449,7 +6601,7 @@ function wrapWithContract(contract, helper, options = {}) {
6449
6601
  await writeAuditLog({
6450
6602
  tenantId: ctx.tenantId,
6451
6603
  brandId: ctx.brandId ?? null,
6452
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6604
+ actor: buildActor(),
6453
6605
  action: contract.auditAction,
6454
6606
  motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' sin permiso para ${reqDesc}`,
6455
6607
  conversacionId: ctx.conversacionId ?? null,
@@ -6481,7 +6633,7 @@ function wrapWithContract(contract, helper, options = {}) {
6481
6633
  await writeAuditLog({
6482
6634
  tenantId: ctx.tenantId,
6483
6635
  brandId: ctx.brandId ?? null,
6484
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6636
+ actor: buildActor(),
6485
6637
  action: contract.auditAction,
6486
6638
  motivo: "Input inv\xE1lido \u2014 Zod parse failed",
6487
6639
  conversacionId: ctx.conversacionId ?? null,
@@ -6497,7 +6649,10 @@ function wrapWithContract(contract, helper, options = {}) {
6497
6649
  };
6498
6650
  }
6499
6651
  const parsedInput = parseResult.data;
6500
- if (contract.requiresConfirmation && !ctx.confirmationGranted) {
6652
+ const effectiveDestructive = contract.isDestructiveForInput?.(parsedInput) ?? contract.destructive;
6653
+ const effectivePublication = contract.isPublicationForInput?.(parsedInput) ?? contract.affectsPublication;
6654
+ const effectiveRequiresConfirmation = contract.requiresConfirmation || effectiveDestructive || effectivePublication;
6655
+ if (effectiveRequiresConfirmation && !ctx.confirmationGranted) {
6501
6656
  const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6502
6657
  return {
6503
6658
  text: confirmMsg,
@@ -6522,7 +6677,7 @@ function wrapWithContract(contract, helper, options = {}) {
6522
6677
  await writeAuditLog({
6523
6678
  tenantId: ctx.tenantId,
6524
6679
  brandId: ctx.brandId ?? null,
6525
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6680
+ actor: buildActor(),
6526
6681
  action: contract.auditAction,
6527
6682
  motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
6528
6683
  conversacionId: ctx.conversacionId ?? null,
@@ -6544,9 +6699,9 @@ function wrapWithContract(contract, helper, options = {}) {
6544
6699
  await writeAuditLog({
6545
6700
  tenantId: ctx.tenantId,
6546
6701
  brandId: ctx.brandId ?? null,
6547
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6702
+ actor: buildActor(),
6548
6703
  action: contract.auditAction,
6549
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
6704
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6550
6705
  conversacionId: ctx.conversacionId ?? null,
6551
6706
  status: "error",
6552
6707
  errorMessage: err?.message ?? "unknown",
@@ -6573,7 +6728,7 @@ function wrapWithContract(contract, helper, options = {}) {
6573
6728
  await writeAuditLog({
6574
6729
  tenantId: ctx.tenantId,
6575
6730
  brandId: ctx.brandId ?? null,
6576
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6731
+ actor: buildActor(),
6577
6732
  action: contract.auditAction,
6578
6733
  motivo: `Output del helper inv\xE1lido \u2014 bug de "${contract.name}"`,
6579
6734
  conversacionId: ctx.conversacionId ?? null,
@@ -6606,7 +6761,7 @@ function wrapWithContract(contract, helper, options = {}) {
6606
6761
  await writeAuditLog({
6607
6762
  tenantId: ctx.tenantId,
6608
6763
  brandId: ctx.brandId ?? null,
6609
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6764
+ actor: buildActor(),
6610
6765
  action: contract.auditAction,
6611
6766
  motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
6612
6767
  conversacionId: ctx.conversacionId ?? null,
@@ -6625,11 +6780,11 @@ function wrapWithContract(contract, helper, options = {}) {
6625
6780
  await writeAuditLog({
6626
6781
  tenantId: ctx.tenantId,
6627
6782
  brandId: ctx.brandId ?? null,
6628
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6783
+ actor: buildActor(),
6629
6784
  action: contract.auditAction,
6630
6785
  targetPath,
6631
6786
  changes,
6632
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
6787
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6633
6788
  conversacionId: ctx.conversacionId ?? null,
6634
6789
  status: "success",
6635
6790
  durationMs: Date.now() - startMs
@@ -6701,41 +6856,54 @@ var ListarRutinasOutputSchema = import_zod35.z.object({
6701
6856
  rutinas: import_zod35.z.array(MartinRutinaSchema)
6702
6857
  });
6703
6858
 
6704
- // src/tools/martinContext.ts
6705
- function buildMartinContext(session, brandId, opts = {}) {
6859
+ // src/tools/buildContext.ts
6860
+ async function buildContext(session, brandId, opts = {}) {
6861
+ const tenantId = session.requireTenant();
6862
+ const ci = session.mcpClientIdentity;
6863
+ let userCtx = null;
6864
+ if (session.userId && getSdkMode() === "admin") {
6865
+ try {
6866
+ userCtx = await getUserContext({
6867
+ db: getAdminDb(),
6868
+ tenantId,
6869
+ uid: session.userId
6870
+ });
6871
+ } catch {
6872
+ userCtx = null;
6873
+ }
6874
+ }
6706
6875
  return {
6707
- tenantId: session.requireTenant(),
6876
+ tenantId,
6708
6877
  brandId,
6709
6878
  user: {
6710
6879
  uid: session.userId ?? "cowork-admin",
6711
- nombre: session.userName ?? "Cowork Admin",
6712
- rol: session.rol ?? "super_admin",
6713
- idiomaPreferido: "es"
6714
- // TODO HITO 8: leer usuarios/{uid}.idiomaPreferido
6880
+ nombre: userCtx?.nombre ?? session.userName ?? "Cowork Admin",
6881
+ rol: userCtx?.rolId ?? session.rol ?? "super_admin",
6882
+ rolNombre: userCtx?.rolNombre ?? null,
6883
+ idiomaPreferido: userCtx?.idiomaPreferido ?? "es",
6884
+ // Any caller entering through this factory is an MCP client (Claude
6885
+ // Desktop, Cursor, custom LLM scripts), NOT the Martin product app.
6886
+ // When Martin product app ships (211.3+), its server-side caller will
6887
+ // override clientType to 'martin' via the same `_martinContext` channel.
6888
+ clientType: "mcp_client",
6889
+ clientMetadata: {
6890
+ mcpClient: ci.mcpClient,
6891
+ mcpClientVersion: ci.mcpClientVersion
6892
+ }
6715
6893
  },
6716
6894
  conversacionId: opts.conversacionId ?? null,
6717
- confirmationGranted: opts.confirmationGranted ?? true,
6895
+ // HITO 6 A7.8: default false — secure default para acciones destructivas.
6896
+ // Tools sin requiresConfirmation lo ignoran; tools con
6897
+ // requiresConfirmation: true necesitan el flag explícito para ejecutar.
6898
+ confirmationGranted: opts.confirmationGranted ?? false,
6718
6899
  doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
6719
6900
  };
6720
6901
  }
6721
6902
  async function dispatchWithContract(args) {
6722
6903
  const { contract, helper, callable, input, ctx } = args;
6723
- if (getMode() === "admin") {
6904
+ if (getSdkMode() === "admin") {
6724
6905
  const db = getAdminDb();
6725
- const permissionResolver = async (args2) => {
6726
- if (args2.userRol === "super_admin") return "completo";
6727
- const ref = db.collection("configuracion").doc(`${args2.tenantId}_roles`);
6728
- const snap = await ref.get();
6729
- if (!snap.exists) return "ninguno";
6730
- const rolesData = snap.data();
6731
- const rolData = rolesData[args2.userRol];
6732
- if (!rolData || rolData.activo === false) return "ninguno";
6733
- const nivel = rolData.permisosPorModulo?.[args2.modulo];
6734
- if (nivel === "completo" || nivel === "editar" || nivel === "ver" || nivel === "ninguno") {
6735
- return nivel;
6736
- }
6737
- return "ninguno";
6738
- };
6906
+ const permissionResolver = (args2) => getUserPermissionLevel({ db, ...args2 });
6739
6907
  const wrapped = wrapWithContract(
6740
6908
  contract,
6741
6909
  async (i) => helper({ ...i, db }),
@@ -6743,15 +6911,20 @@ async function dispatchWithContract(args) {
6743
6911
  );
6744
6912
  return wrapped(input, ctx);
6745
6913
  }
6746
- return callable({
6914
+ const result = await callable({
6747
6915
  ...input,
6748
6916
  _martinContext: {
6749
6917
  conversacionId: ctx.conversacionId ?? null,
6750
6918
  confirmationGranted: ctx.confirmationGranted === true,
6751
6919
  doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
6752
- userIdiomaPreferido: ctx.user.idiomaPreferido
6920
+ userIdiomaPreferido: ctx.user.idiomaPreferido,
6921
+ // HITO 6 A7.5: propagar clientType + identidad concreta para que
6922
+ // el factory CF arme actor.type + actor.metadata en el audit log.
6923
+ clientType: ctx.user.clientType ?? "mcp_client",
6924
+ clientMetadata: ctx.user.clientMetadata ?? null
6753
6925
  }
6754
6926
  });
6927
+ return result;
6755
6928
  }
6756
6929
 
6757
6930
  // src/services/marketingHelperCallables.ts
@@ -6883,7 +7056,7 @@ async function generateTextEmbedding(text, session) {
6883
7056
  if (!trimmed) {
6884
7057
  throw new Error("generateTextEmbedding: text no puede estar vacio");
6885
7058
  }
6886
- if (getMode() === "client") {
7059
+ if (getSdkMode() === "client") {
6887
7060
  return null;
6888
7061
  }
6889
7062
  return callEmbeddingCF({ text: trimmed }, session);
@@ -6977,7 +7150,7 @@ async function findNearestInCollection(params) {
6977
7150
  `findNearestInCollection: vectorField obligatorio (embeddingText | embeddingImage), recibido ${vectorField}`
6978
7151
  );
6979
7152
  }
6980
- if (getMode() === "client") {
7153
+ if (getSdkMode() === "client") {
6981
7154
  if (!queryText || typeof queryText !== "string") {
6982
7155
  throw new Error(
6983
7156
  "findNearestInCollection en Modo B (client) requiere queryText. El MCP cliente no puede generar embeddings localmente; la CF puente marketingVectorSearchCallable los genera server-side."
@@ -7129,8 +7302,8 @@ var LimitSchema = import_zod36.z.object({
7129
7302
  async function findContentForTopicHandler(input, session) {
7130
7303
  const tenantId = session.requireTenant();
7131
7304
  const brandId = input.brandId;
7132
- const mode2 = getMode();
7133
- const r = mode2 === "admin" ? await contentFinder({
7305
+ const mode = getSdkMode();
7306
+ const r = mode === "admin" ? await contentFinder({
7134
7307
  db: getAdminDb(),
7135
7308
  tenantId,
7136
7309
  brandId,
@@ -7218,7 +7391,7 @@ Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = e
7218
7391
  diversidad: import_zod36.z.boolean().default(true),
7219
7392
  mode: import_zod36.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
7220
7393
  },
7221
- async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
7394
+ async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode }) => {
7222
7395
  const brandId = inputBrandId ?? session.requireBrand();
7223
7396
  const resolvedInclude = include || {
7224
7397
  products: true,
@@ -7241,7 +7414,7 @@ Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = e
7241
7414
  include: resolvedInclude,
7242
7415
  limit: resolvedLimit,
7243
7416
  diversidad,
7244
- mode: mode2
7417
+ mode
7245
7418
  },
7246
7419
  session
7247
7420
  );
@@ -7329,7 +7502,7 @@ USAR: antes de generar contenido de cualquier slot del calendario.`,
7329
7502
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
7330
7503
  const tenantId = session.requireTenant();
7331
7504
  const brandId = inputBrandId ?? session.requireBrand();
7332
- const result = getMode() === "admin" ? await slotAssetFinder({
7505
+ const result = getSdkMode() === "admin" ? await slotAssetFinder({
7333
7506
  db: getAdminDb(),
7334
7507
  tenantId,
7335
7508
  brandId,
@@ -7370,7 +7543,7 @@ Luego llama execute_photo_edit con tu analisis y prompt.`,
7370
7543
  async ({ fotoId }) => {
7371
7544
  const tenantId = session.requireTenant();
7372
7545
  const lang = await resolveTenantIdioma(tenantId);
7373
- const result = getMode() === "admin" ? await photoDirectorPlan({
7546
+ const result = getSdkMode() === "admin" ? await photoDirectorPlan({
7374
7547
  db: getAdminDb(),
7375
7548
  tenantId,
7376
7549
  fotoId,
@@ -7410,7 +7583,7 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
7410
7583
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7411
7584
  const tenantId = session.requireTenant();
7412
7585
  const lang = await resolveTenantIdioma(tenantId);
7413
- if (getMode() === "admin") {
7586
+ if (getSdkMode() === "admin") {
7414
7587
  const executePhotoEditAdapter = async (payload) => {
7415
7588
  const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
7416
7589
  const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
@@ -7647,7 +7820,7 @@ USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/
7647
7820
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
7648
7821
  const tenantId = session.requireTenant();
7649
7822
  const brandId = inputBrandId ?? session.requireBrand();
7650
- const result = getMode() === "admin" ? await canvaTemplateSelector({
7823
+ const result = getSdkMode() === "admin" ? await canvaTemplateSelector({
7651
7824
  db: getAdminDb(),
7652
7825
  tenantId,
7653
7826
  brandId,
@@ -7664,7 +7837,7 @@ USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/
7664
7837
  );
7665
7838
  server.tool(
7666
7839
  "request_photo_shoot",
7667
- `Agrega necesidades al FotoBriefing semanal del tenant. Lo usa Claude cuando detecta gaps fotograficos mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
7840
+ `Agrega necesidades al FotoBriefing semanal del tenant. The LLM uses this when it detects photo gaps mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
7668
7841
 
7669
7842
  EFECTO: merge de necesidades en marketing_fotobriefings/{tenantId}_{brandId}_{semana}. Si el doc no existe, lo crea. Idempotente por tema.
7670
7843
 
@@ -7833,7 +8006,7 @@ function registerMarketingTools(server, session) {
7833
8006
  const tenantId = session.requireTenant();
7834
8007
  const brandId = inputBrandId ?? session.requireBrand();
7835
8008
  const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
7836
- const ctx = buildMartinContext(session, brandId);
8009
+ const ctx = await buildContext(session, brandId);
7837
8010
  const result = await dispatchWithContract({
7838
8011
  contract: getCalendarContract,
7839
8012
  helper: getCalendar,
@@ -7918,32 +8091,32 @@ function registerMarketingTools(server, session) {
7918
8091
  );
7919
8092
  server.tool(
7920
8093
  "generate_marketing_plan",
7921
- "Prepara los datos necesarios para generar un plan de marketing estrategico. Retorna snapshot SEO + productos para que Claude genere el plan con el system prompt.",
8094
+ "Aggregate the data needed to generate a strategic marketing plan: SEO snapshot + products. The LLM uses the system prompt to generate the plan from this payload.",
7922
8095
  {
7923
8096
  brandId: import_zod38.z.string().optional().describe("ID de la brand")
7924
8097
  },
7925
8098
  async ({ brandId: inputBrandId }) => {
7926
8099
  const tenantId = session.requireTenant();
7927
8100
  const brandId = inputBrandId ?? session.requireBrand();
7928
- const result = getMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
8101
+ const result = getSdkMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
7929
8102
  const payload = result.ok ? result.payload : result;
7930
8103
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7931
8104
  }
7932
8105
  );
7933
8106
  server.tool(
7934
8107
  "save_marketing_plan",
7935
- "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en tenants/{tenantId}/marketing_config/{brandId}.plan.",
8108
+ "Save a marketing plan to the brand configuration. Writes to tenants/{tenantId}/marketing_config/{brandId}.plan. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
7936
8109
  {
7937
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
7938
- plan: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
8110
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8111
+ plan: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full marketing plan object. Common fields: keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, coleccionesPriorizadas, blogStrategy.")
7939
8112
  },
7940
8113
  async ({ brandId: inputBrandId, plan }) => {
7941
8114
  const tenantId = session.requireTenant();
7942
8115
  const brandId = inputBrandId ?? session.requireBrand();
7943
- const ctx = buildMartinContext(session, brandId);
8116
+ const ctx = await buildContext(session, brandId);
7944
8117
  const result = await dispatchWithContract({
7945
8118
  contract: planWriterSaveContract,
7946
- helper: planWriter.save,
8119
+ helper: ({ db, ...rest }) => planWriter.save({ db, ...rest }),
7947
8120
  callable: callPlanWriterSave,
7948
8121
  input: { tenantId, brandId, plan },
7949
8122
  ctx
@@ -7952,8 +8125,8 @@ function registerMarketingTools(server, session) {
7952
8125
  ok: false,
7953
8126
  state: result.state,
7954
8127
  mensaje: result.text,
7955
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
7956
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
8128
+ // HITO 6 A6.7: include structured Zod details so
8129
+ // the LLM can self-recover on the next attempt.
7957
8130
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
7958
8131
  };
7959
8132
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -7970,20 +8143,20 @@ function registerMarketingTools(server, session) {
7970
8143
  async ({ brandId: inputBrandId, field, value }) => {
7971
8144
  const tenantId = session.requireTenant();
7972
8145
  const brandId = inputBrandId ?? session.requireBrand();
7973
- const result = getMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
8146
+ const result = getSdkMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
7974
8147
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7975
8148
  }
7976
8149
  );
7977
8150
  server.tool(
7978
8151
  "generate_brand_brief",
7979
- "Prepara todos los datos del negocio para que Claude genere un Brand Brief pre-llenado. Retorna Shopify + SEO + GBP + tenant data. Claude genera el brief, luego usa save_brand_brief para guardar.",
8152
+ "Aggregate all business data needed to generate a Brand Brief: Shopify (products, collections, orders, shop info), SEO snapshot, GBP profiles, scraped site_content, brand config, tenant locations. Read-only \u2014 does NOT write the brief. After receiving the payload, generate the brief content and save it via save_brand_brief.",
7980
8153
  {
7981
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
8154
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
7982
8155
  },
7983
8156
  async ({ brandId: inputBrandId }) => {
7984
8157
  const tenantId = session.requireTenant();
7985
8158
  const brandId = inputBrandId ?? session.requireBrand();
7986
- const ctx = buildMartinContext(session, brandId);
8159
+ const ctx = await buildContext(session, brandId);
7987
8160
  const result = await dispatchWithContract({
7988
8161
  contract: brandBriefBuilderContract,
7989
8162
  helper: brandBriefBuilder,
@@ -8004,21 +8177,21 @@ function registerMarketingTools(server, session) {
8004
8177
  );
8005
8178
  server.tool(
8006
8179
  "save_brand_brief",
8007
- "Guarda el Brand Brief generado por Claude en tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
8180
+ "Save the Brand Brief at tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Partial merge \u2014 only the brandBrief field is overwritten.",
8008
8181
  {
8009
8182
  brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8010
- brandBrief: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Brand Brief completo generado por Claude")
8183
+ brandBrief: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full Brand Brief object.")
8011
8184
  },
8012
8185
  async ({ brandId: inputBrandId, brandBrief }) => {
8013
8186
  const tenantId = session.requireTenant();
8014
8187
  const brandId = inputBrandId ?? session.requireBrand();
8015
- const result = getMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
8188
+ const result = getSdkMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
8016
8189
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8017
8190
  }
8018
8191
  );
8019
8192
  server.tool(
8020
8193
  "generate_weekly_content",
8021
- "Prepara datos del calendario + fotos + plan para generar el contenido de la semana. Claude genera con el system prompt, luego usa save_generated_content para guardar.",
8194
+ "Aggregate calendar + photos + plan data to generate the week's content. The LLM generates with the system prompt, then uses save_generated_content to persist.",
8022
8195
  {
8023
8196
  brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8024
8197
  semana: import_zod38.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
@@ -8027,16 +8200,16 @@ function registerMarketingTools(server, session) {
8027
8200
  async ({ brandId: inputBrandId, semana, modo }) => {
8028
8201
  const tenantId = session.requireTenant();
8029
8202
  const brandId = inputBrandId ?? session.requireBrand();
8030
- const result = getMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
8203
+ const result = getSdkMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
8031
8204
  const payload = result.ok ? result.payload : result;
8032
8205
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
8033
8206
  }
8034
8207
  );
8035
8208
  server.tool(
8036
8209
  "save_generated_content",
8037
- `Guarda contenido generado por Claude en Firestore. Usa buildContenido para validar la estructura.
8210
+ `Save generated content to Firestore. Uses buildContenido to validate the structure.
8038
8211
 
8039
- IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa fotoId aqu\xED. Sin fotoId el post se publica sin imagen.`,
8212
+ IMPORTANT: If you selected a photo with get_photos_for_slot, ALWAYS pass fotoId here. Without fotoId the post will be published without an image.`,
8040
8213
  {
8041
8214
  brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8042
8215
  plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
@@ -8051,7 +8224,7 @@ IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa foto
8051
8224
  const tenantId = session.requireTenant();
8052
8225
  const brandId = inputBrandId ?? session.requireBrand();
8053
8226
  try {
8054
- const result = getMode() === "admin" ? await contenidoWriter({
8227
+ const result = getSdkMode() === "admin" ? await contenidoWriter({
8055
8228
  db: getAdminDb(),
8056
8229
  tenantId,
8057
8230
  brandId,
@@ -8100,7 +8273,7 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8100
8273
  },
8101
8274
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
8102
8275
  const tenantId = session.requireTenant();
8103
- const result = getMode() === "admin" ? await contenidoUpdater({
8276
+ const result = getSdkMode() === "admin" ? await contenidoUpdater({
8104
8277
  db: getAdminDb(),
8105
8278
  tenantId,
8106
8279
  contenidoId,
@@ -8125,26 +8298,26 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8125
8298
  );
8126
8299
  server.tool(
8127
8300
  "add_calendar_slot",
8128
- "Agrega un slot NUEVO al calendario editorial. Para modificar un slot existente usa update_calendar_slot.",
8301
+ "Add a NEW slot to the editorial calendar. To modify an existing slot use update_calendar_slot instead.",
8129
8302
  {
8130
- brandId: import_zod38.z.string().describe("ID de la brand"),
8131
- mes: import_zod38.z.string().describe("Mes del calendario en formato YYYY-MM"),
8132
- semana: import_zod38.z.number().describe("Numero de semana (1-5)"),
8303
+ brandId: import_zod38.z.string().describe('Brand ID (e.g. "ponch", "teleglobos").'),
8304
+ mes: import_zod38.z.string().describe('Calendar month in YYYY-MM format (e.g. "2026-05").'),
8305
+ semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8133
8306
  slot: import_zod38.z.object({
8134
- dia: import_zod38.z.string().describe("Fecha del slot en formato YYYY-MM-DD. Debe caer dentro del rango fechaInicio/fechaFin de la semana indicada."),
8135
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
8136
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Tipo de contenido"),
8137
- keyword: import_zod38.z.string().describe("Keyword principal del contenido"),
8138
- tema: import_zod38.z.string().optional().describe("Tema del contenido. OMITE el campo si no aplica, no envies cadena vacia ni null."),
8139
- productoId: import_zod38.z.string().optional().describe("ID de producto vinculado. OMITE el campo si no aplica."),
8140
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("Estado inicial. OMITE si quieres default 'planificado'."),
8141
- locationId: import_zod38.z.string().optional().describe("GBP location ID \u2014 solo si plataforma=gbp y multi-sucursal. OMITE si no aplica."),
8142
- locationNombre: import_zod38.z.string().optional().describe("Nombre de la sucursal GBP. OMITE si no aplica.")
8143
- }).describe("Datos del slot nuevo")
8307
+ dia: import_zod38.z.string().describe("Slot date in YYYY-MM-DD format. Must fall within the week's fechaInicio/fechaFin range."),
8308
+ plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target publishing platform."),
8309
+ tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type."),
8310
+ keyword: import_zod38.z.string().describe("Primary keyword for the content."),
8311
+ tema: import_zod38.z.string().optional().describe("Content topic/theme. OMIT this field if it does not apply. Do NOT send empty string or null."),
8312
+ productoId: import_zod38.z.string().optional().describe("Linked product ID. OMIT this field if no product is linked."),
8313
+ estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe('Initial status. OMIT to use default "planificado".'),
8314
+ locationId: import_zod38.z.string().optional().describe("GBP location ID \u2014 only when plataforma=gbp AND tenant is multi-location. OMIT otherwise."),
8315
+ locationNombre: import_zod38.z.string().optional().describe("Human-readable GBP location name. OMIT if locationId is not provided.")
8316
+ }).describe("New slot data.")
8144
8317
  },
8145
8318
  async ({ brandId, mes, semana, slot }) => {
8146
8319
  const tenantId = session.requireTenant();
8147
- const ctx = buildMartinContext(session, brandId);
8320
+ const ctx = await buildContext(session, brandId);
8148
8321
  const result = await dispatchWithContract({
8149
8322
  contract: addCalendarSlotContract,
8150
8323
  helper: addCalendarSlot,
@@ -8163,40 +8336,48 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8163
8336
  );
8164
8337
  server.tool(
8165
8338
  "update_calendar_slot",
8166
- "MODIFICA un slot EXISTENTE del calendario editorial. Para crear un slot nuevo usa add_calendar_slot. Si slotIndex no existe, retorna error SLOT_NOT_FOUND.",
8339
+ "MODIFY an EXISTING slot in the editorial calendar. To create a new slot use add_calendar_slot. If slotIndex does not exist, returns error SLOT_NOT_FOUND.",
8167
8340
  {
8168
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8169
- mes: import_zod38.z.string().describe("Mes del calendario (YYYY-MM)"),
8170
- semana: import_zod38.z.number().describe("Numero de semana (1-5)"),
8171
- slotIndex: import_zod38.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
8341
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8342
+ mes: import_zod38.z.string().describe("Calendar month in YYYY-MM format."),
8343
+ semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8344
+ slotIndex: import_zod38.z.number().describe("Slot index (0-based). Must point to an existing slot \u2014 to add new slots use add_calendar_slot."),
8172
8345
  cambios: import_zod38.z.object({
8173
- dia: import_zod38.z.string().nullable().optional(),
8174
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
8175
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
8176
- keyword: import_zod38.z.string().nullable().optional(),
8177
- tema: import_zod38.z.string().nullable().optional(),
8178
- productoId: import_zod38.z.string().nullable().optional(),
8179
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
8180
- contenidoRef: import_zod38.z.string().nullable().optional(),
8181
- fotoIdAsignada: import_zod38.z.string().nullable().optional(),
8182
- notas: import_zod38.z.array(NotaCalendarioSchema).optional(),
8183
- locationId: import_zod38.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
8184
- locationNombre: import_zod38.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
8185
- }).strict().describe("Campos del slot. Solo acepta campos validos del schema. Campos no reconocidos seran rechazados."),
8346
+ dia: import_zod38.z.string().nullable().optional().describe("Slot date in YYYY-MM-DD. OMIT if not changing date. Use null to explicitly clear."),
8347
+ plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional().describe("OMIT if not changing platform."),
8348
+ tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional().describe("OMIT if not changing content type."),
8349
+ keyword: import_zod38.z.string().nullable().optional().describe("OMIT if not changing keyword."),
8350
+ tema: import_zod38.z.string().nullable().optional().describe("OMIT if not changing topic."),
8351
+ productoId: import_zod38.z.string().nullable().optional().describe("OMIT if not changing linked product. Use null to unlink."),
8352
+ estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("OMIT if not changing status manually."),
8353
+ contenidoRef: import_zod38.z.string().nullable().optional().describe("OMIT \u2014 managed by the system, not by callers."),
8354
+ fotoIdAsignada: import_zod38.z.string().nullable().optional().describe("Use assign_photo_to_content for photos. OMIT here."),
8355
+ notas: import_zod38.z.array(NotaCalendarioSchema).optional().describe("Append-only notes for the slot."),
8356
+ locationId: import_zod38.z.string().nullable().optional().describe("GBP location ID \u2014 only when plataforma=gbp."),
8357
+ locationNombre: import_zod38.z.string().nullable().optional().describe("Human-readable GBP location name (for UI).")
8358
+ }).strict().describe("Fields to update on the slot. Only declared fields accepted; unknown fields are rejected. OMIT any field you are not changing."),
8186
8359
  accionContenidoExistente: import_zod38.z.union([
8187
8360
  import_zod38.z.enum(["descartar", "nuevo_slot", "mantener"]),
8188
- import_zod38.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
8361
+ import_zod38.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Format: mover:semana:N:slot:M")
8189
8362
  ]).optional().describe(
8190
- "Decision del tenant cuando el slot ya tiene un contenidoRef existente Y los cambios tocan keyword/tema/plataforma/tipo. Requerido en ese escenario (si no se pasa, el helper retorna ACCION_CONTENIDO_EXISTENTE_REQUIRED con las 4 opciones para que preguntes al tenant). Valores: 'descartar' (marca el contenido existente como descartado y aplica cambios al slot) | 'mover:semana:N:slot:M' (mueve el contenido existente al slot destino vacio y aplica cambios al slot origen) | 'nuevo_slot' (NO toca este slot ni su contenido; crea un slot nuevo el mismo dia con los cambios \u2014 util para multiples publicaciones/dia) | 'mantener' (conserva el contenido existente aqui y aplica los cambios igualmente \u2014 caso typo fix)."
8363
+ 'Required ONLY when the slot already has a contenidoRef AND the cambios touch semantic fields (keyword/tema/plataforma/tipo). In that case, the helper returns ACCION_CONTENIDO_EXISTENTE_REQUIRED with the 4 options listed below \u2014 ask the tenant which one to apply. OMIT this field in any other case. Do NOT send null. Valid values: "descartar" (mark existing content as discarded and apply changes to this slot) | "mover:semana:N:slot:M" (move existing content to target empty slot N/M and apply changes to origin slot) | "nuevo_slot" (do NOT touch this slot or its content; create a new slot same day with the changes \u2014 useful for multiple posts per day) | "mantener" (keep existing content here and apply changes anyway \u2014 typo-fix case where old content stays valid).'
8191
8364
  )
8192
8365
  },
8193
8366
  async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
8194
8367
  const tenantId = session.requireTenant();
8195
8368
  const brandId = inputBrandId ?? session.requireBrand();
8196
- const ctx = buildMartinContext(session, brandId);
8369
+ const ctx = await buildContext(session, brandId);
8197
8370
  const result = await dispatchWithContract({
8198
8371
  contract: calendarSlotUpdaterContract,
8199
- helper: calendarSlotUpdater,
8372
+ // Wrap en arrow para que TS infiera el tipo del input desde el
8373
+ // schema del contract (acepta `cambios: Record<string, unknown>`),
8374
+ // no desde la interface estricta CalendarSlotUpdaterInput.
8375
+ // El helper internamente acepta el shape — Zod ya valido en el wrapper.
8376
+ helper: (input) => calendarSlotUpdater({
8377
+ ...input,
8378
+ cambios: input.cambios,
8379
+ accionContenidoExistente: input.accionContenidoExistente
8380
+ }),
8200
8381
  callable: callCalendarSlotUpdater,
8201
8382
  input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
8202
8383
  ctx
@@ -8205,8 +8386,8 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8205
8386
  ok: false,
8206
8387
  state: result.state,
8207
8388
  mensaje: result.text,
8208
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
8209
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
8389
+ // HITO 6 A6.7: include structured Zod details so
8390
+ // the LLM can self-recover on the next attempt.
8210
8391
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8211
8392
  };
8212
8393
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -8229,7 +8410,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8229
8410
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
8230
8411
  const tenantId = session.requireTenant();
8231
8412
  const brandId = session.requireBrand();
8232
- const result = getMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8413
+ const result = getSdkMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8233
8414
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8234
8415
  }
8235
8416
  );
@@ -8378,7 +8559,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8378
8559
  async ({ brandId: inputBrandId, suggestions }) => {
8379
8560
  const tenantId = session.requireTenant();
8380
8561
  const brandId = inputBrandId ?? session.requireBrand();
8381
- const result = getMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8562
+ const result = getSdkMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8382
8563
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8383
8564
  }
8384
8565
  );
@@ -8386,8 +8567,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8386
8567
 
8387
8568
  // src/index.ts
8388
8569
  async function buildSystemPrompt2(session) {
8389
- const mode2 = getMode();
8390
- if (mode2 === "admin") {
8570
+ const mode = getSdkMode();
8571
+ if (mode === "admin") {
8391
8572
  const db = getAdminDb();
8392
8573
  return buildSystemPrompt({
8393
8574
  db,
@@ -8404,7 +8585,9 @@ async function buildSystemPrompt2(session) {
8404
8585
  async function main() {
8405
8586
  const authContext = resolveAuth();
8406
8587
  const session = new Session(authContext);
8407
- console.error(`[ponch-mcp] Modo: ${authContext.mode}`);
8588
+ console.error(
8589
+ `[ponch-mcp] Auth resuelto. canSwitchTenant=${authContext.canSwitchTenant} tenantId=${authContext.tenantId ?? "(none)"} rol=${authContext.rol ?? "(none)"}`
8590
+ );
8408
8591
  let firebaseReady = false;
8409
8592
  if (authContext.serviceAccountPath) {
8410
8593
  initFirebaseAdmin(authContext.serviceAccountPath);
@@ -8416,7 +8599,7 @@ async function main() {
8416
8599
  console.error(`[ponch-mcp] Firebase Client autenticado. Tenant: ${authContext.tenantId}`);
8417
8600
  } else {
8418
8601
  console.error("[ponch-mcp] Token expirado. Usa connect_account para reconectar.");
8419
- session.forceMode("tenant");
8602
+ session.revokeCrossTenant();
8420
8603
  }
8421
8604
  } else {
8422
8605
  console.error("[ponch-mcp] Sin conexion. Usa connect_account para conectar.");
@@ -8425,6 +8608,13 @@ async function main() {
8425
8608
  name: "ponch",
8426
8609
  version: "1.0.1"
8427
8610
  });
8611
+ server.server.oninitialized = () => {
8612
+ const ci = server.server.getClientVersion();
8613
+ if (ci) {
8614
+ session.setMcpClientIdentity(ci.name ?? null, ci.version ?? null);
8615
+ console.error(`[ponch-mcp] Cliente MCP: ${ci.name ?? "(sin name)"} v${ci.version ?? "(sin version)"}`);
8616
+ }
8617
+ };
8428
8618
  server.resource(
8429
8619
  "system-prompt",
8430
8620
  "ponch://system-prompt",