@agifyai/leadify-mcp 1.5.0 → 1.5.2

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.
@@ -1,97 +1,158 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
3
  import { toolResult, toolError, handleToolError } from "../types.js";
4
- // ─── Mirrored backend constraints ──────────────────────────────────────────
4
+ // ─── Mirror of the backend companyInfo v2 schema ───────────────────────────
5
5
  //
6
- // The companyInfo schema lives in the Leadify backend (apps/server/src/lib/
7
- // data-room-schemas.ts most likely). The MCP cannot import it directly, so
8
- // the constraints below are a hand-mirrored subset based on observed 422
9
- // responses + GET /api/data-room payload shapes. The mirror is INTENTIONALLY
10
- // strict on what we know and permissive (passthrough) on what we don't —
11
- // so callers get up-front guidance for the documented fields and the
12
- // backend still owns final validation for everything else.
6
+ // SOURCE OF TRUTH:
7
+ // github.com/AgifyAI/leadify → apps/server/src/lib/data-room-schemas.ts
13
8
  //
14
- // Known constraints (source: observed 422 path/expected pairs):
15
- // - products[].category ∈ {saas, medical_device, service, mixed}
16
- // - snapshot.stage ∈ {early, growth, scaling, enterprise}
17
- // - snapshot.constraints[].type ∈ {regulatory, brand, legal}
18
- // - snapshot.constraints[].note ≤ 150 chars
19
- // - snapshot.pitchOneLiner ≤ 200 chars
9
+ // Keep in sync. The backend validates every PUT against the full v2 schema
10
+ // and REPLACES companyInfo wholesale (no server-side merge). Every granular
11
+ // patch tool below therefore fetches the current companyInfo, mutates the
12
+ // target block, and PUTs the FULL merged object back.
20
13
  //
21
- // If you hit a 422 for a field/constraint not listed above, extend the
22
- // mirror here and republish. The asymmetric upstream Zod (.parse instead of
23
- // .safeParse) surfaces one error at a time — see TODO at the bottom of this
24
- // file re: requesting backend .safeParse cumulative errors.
14
+ // The backend now returns ALL zod issues at once under `issues[]` in the
15
+ // 422 body (see formatZodError in apps/server/src/lib/persona-schemas.ts).
16
+ // handleToolError in ../types.ts surfaces that array in the tool error.
17
+ const COMPANY_INFO_SCHEMA_VERSION = 2;
18
+ const STAGES = ["early", "growth", "scaling", "enterprise"];
25
19
  const PRODUCT_CATEGORIES = ["saas", "medical_device", "service", "mixed"];
26
- const STAGE_VALUES = ["early", "growth", "scaling", "enterprise"];
20
+ const CE_CLASSES = ["I", "IIa", "IIb", "III"];
21
+ const REIMBURSEMENT_STATUSES = ["none", "in_progress", "local", "national"];
22
+ const PRICING_MODELS = ["saas_per_site", "saas_per_exam", "capex", "service", "mixed"];
23
+ const CURRENCIES = ["EUR", "USD"];
27
24
  const CONSTRAINT_TYPES = ["regulatory", "brand", "legal"];
28
- const productSchema = z
29
- .object({
30
- name: z.string().min(1).describe("Product name (required)."),
31
- category: z
32
- .enum(PRODUCT_CATEGORIES)
33
- .describe("Product classification. One of: saas, medical_device, service, mixed."),
34
- description: z.string().optional().describe("Short description."),
35
- valueProposition: z.string().optional(),
36
- targetCustomer: z.string().optional(),
37
- pricing: z.string().optional(),
38
- })
39
- .passthrough()
40
- .describe("Single product entry. name + category are required (category is a strict enum).");
41
- const constraintSchema = z
42
- .object({
43
- type: z
44
- .enum(CONSTRAINT_TYPES)
45
- .describe("Constraint type. One of: regulatory, brand, legal."),
46
- note: z
47
- .string()
48
- .max(150)
49
- .describe("Short note describing the constraint. Max 150 chars."),
50
- })
51
- .passthrough()
52
- .describe("Single business constraint (regulatory / brand / legal). Note capped at 150 chars.");
53
- const snapshotSchema = z
54
- .object({
55
- stage: z
56
- .enum(STAGE_VALUES)
57
- .optional()
58
- .describe("Company stage. One of: early, growth, scaling, enterprise."),
59
- pitchOneLiner: z
60
- .string()
61
- .max(200)
62
- .optional()
63
- .describe("One-liner pitch. Max 200 chars."),
64
- constraints: z
65
- .array(constraintSchema)
66
- .optional()
67
- .describe("Business constraints. Each entry: {type ∈ regulatory|brand|legal, note ≤150 chars}."),
68
- })
69
- .passthrough()
70
- .describe("Snapshot block. stage + pitchOneLiner + constraints[] are validated; other keys pass through.");
71
- const senderSchema = z
25
+ const GEO_REGIONS = [
26
+ "eu_western_fr",
27
+ "eu_western_others",
28
+ "eu_nordics",
29
+ "eu_central_eastern",
30
+ "north_america",
31
+ "apac",
32
+ "latam",
33
+ "mena_africa",
34
+ ];
35
+ const CHAR_LIMITS = {
36
+ name: 200,
37
+ website: 500,
38
+ pitchOneLiner: 200,
39
+ marketSpecialty: 100,
40
+ constraintLabel: 150,
41
+ constraintNote: 150,
42
+ topUseCase: 150,
43
+ differentiator: 150,
44
+ notFor: 200,
45
+ triggerItem: 150,
46
+ defaultBaseline: 200,
47
+ keyMetric: 150,
48
+ miniStory: 300,
49
+ productName: 100,
50
+ categoryDetail: 150,
51
+ outcomeUser: 200,
52
+ outcomeBuyer: 200,
53
+ establishmentType: 150,
54
+ technicalPrereq: 200,
55
+ championRole: 150,
56
+ budgetDeciderRole: 150,
57
+ coDeciderRole: 100,
58
+ forbiddenWord: 100,
59
+ };
60
+ const PRODUCT_HARD_LIMIT = 5;
61
+ // ─── Sub-schemas ───────────────────────────────────────────────────────────
62
+ const constraintSchema = z.object({
63
+ label: z.string().max(CHAR_LIMITS.constraintLabel),
64
+ type: z.enum(CONSTRAINT_TYPES),
65
+ note: z.string().max(CHAR_LIMITS.constraintNote).optional(),
66
+ });
67
+ const geoSchema = z.object({
68
+ regions: z.array(z.enum(GEO_REGIONS)).default([]),
69
+ countriesIncluded: z.array(z.string().length(2)).default([]),
70
+ countriesExcluded: z.array(z.string().length(2)).default([]),
71
+ });
72
+ const snapshotSchema = z.object({
73
+ pitchOneLiner: z.string().max(CHAR_LIMITS.pitchOneLiner),
74
+ marketSpecialty: z.string().max(CHAR_LIMITS.marketSpecialty),
75
+ stage: z.enum(STAGES),
76
+ salesTeamSizeFR: z.number().int().min(0),
77
+ geo: geoSchema,
78
+ constraints: z.array(constraintSchema).max(10).default([]),
79
+ });
80
+ const regulatorySchema = z.object({
81
+ ceMarked: z.boolean(),
82
+ ceClass: z.enum(CE_CLASSES).nullable(),
83
+ reimbursementStatus: z.enum(REIMBURSEMENT_STATUSES),
84
+ aoRequired: z.boolean(),
85
+ });
86
+ const productSchema = z.object({
87
+ name: z.string().max(CHAR_LIMITS.productName),
88
+ category: z.enum(PRODUCT_CATEGORIES),
89
+ categoryDetail: z.string().max(CHAR_LIMITS.categoryDetail).optional(),
90
+ outcomeUser: z.string().max(CHAR_LIMITS.outcomeUser),
91
+ outcomeBuyer: z.string().max(CHAR_LIMITS.outcomeBuyer),
92
+ topUseCases: z.array(z.string().max(CHAR_LIMITS.topUseCase)).max(3).default([]),
93
+ differentiators: z.array(z.string().max(CHAR_LIMITS.differentiator)).max(3).default([]),
94
+ notFor: z.string().max(CHAR_LIMITS.notFor),
95
+ regulatory: regulatorySchema,
96
+ });
97
+ const economicsSchema = z
72
98
  .object({
73
- name: z.string().describe("Default sender display name (required if sender is set)."),
74
- title: z.string().optional(),
75
- email: z.string().email().optional(),
76
- })
77
- .passthrough();
78
- // Full companyInfo schema — exposed by describe_company_info_schema and
79
- // reused by the wholesale update_data_room escape hatch.
80
- const companyInfoSchema = z
99
+ pricingModel: z.enum(PRICING_MODELS),
100
+ ticketRange: z
101
+ .object({
102
+ min: z.number().min(0),
103
+ max: z.number().min(0),
104
+ currency: z.enum(CURRENCIES),
105
+ })
106
+ .refine((r) => r.min <= r.max, {
107
+ message: "ticketRange.min must be ≤ ticketRange.max",
108
+ path: ["min"],
109
+ }),
110
+ salesCycleMonths: z
111
+ .object({
112
+ min: z.number().int().min(0),
113
+ max: z.number().int().min(0),
114
+ })
115
+ .refine((r) => r.min <= r.max, {
116
+ message: "salesCycleMonths.min must be ≤ salesCycleMonths.max",
117
+ path: ["min"],
118
+ }),
119
+ triggers: z.array(z.string().max(CHAR_LIMITS.triggerItem)).max(5).default([]),
120
+ defaultBaseline: z.string().max(CHAR_LIMITS.defaultBaseline),
121
+ });
122
+ const productICPSchema = z.object({
123
+ productId: z.string().max(CHAR_LIMITS.productName),
124
+ establishmentType: z.string().max(CHAR_LIMITS.establishmentType),
125
+ technicalPrereqs: z.string().max(CHAR_LIMITS.technicalPrereq),
126
+ roles: z.object({
127
+ champion: z.string().max(CHAR_LIMITS.championRole),
128
+ budgetDecider: z.string().max(CHAR_LIMITS.budgetDeciderRole),
129
+ coDeciders: z.array(z.string().max(CHAR_LIMITS.coDeciderRole)).max(5).default([]),
130
+ }),
131
+ });
132
+ const proofWordingSchema = z.object({
133
+ keyMetrics: z.array(z.string().max(CHAR_LIMITS.keyMetric)).max(3).default([]),
134
+ miniStories: z.array(z.string().max(CHAR_LIMITS.miniStory)).max(2).default([]),
135
+ forbiddenWords: z.array(z.string().max(CHAR_LIMITS.forbiddenWord)).default([]),
136
+ });
137
+ const companyInfoV2Schema = z
81
138
  .object({
82
- name: z.string().optional().describe("Company name."),
83
- sector: z.string().optional().describe("Sector / industry."),
84
- description: z.string().optional().describe("Company description."),
85
- size: z.string().optional().describe("Company size (e.g. '10-50', '200+')."),
86
- website: z.string().url().optional().describe("Company website URL."),
87
- valueProposition: z.string().optional().describe("Top-level value prop."),
88
- competitors: z.array(z.string()).optional().describe("Competitor names."),
89
- targetRegions: z.array(z.string()).optional().describe("Geographic regions."),
90
- sender: senderSchema.optional().describe("Default sender identity."),
91
- products: z.array(productSchema).optional(),
92
- snapshot: snapshotSchema.optional(),
139
+ schemaVersion: z.literal(COMPANY_INFO_SCHEMA_VERSION),
140
+ name: z.string().max(CHAR_LIMITS.name),
141
+ website: z.string().max(CHAR_LIMITS.website).optional(),
142
+ snapshot: snapshotSchema,
143
+ products: z.array(productSchema).max(PRODUCT_HARD_LIMIT).default([]),
144
+ economics: economicsSchema,
145
+ productICPs: z.array(productICPSchema).max(PRODUCT_HARD_LIMIT).default([]),
146
+ proofWording: proofWordingSchema,
93
147
  })
94
- .passthrough();
148
+ .refine((data) => {
149
+ const productNames = new Set(data.products.map((p) => p.name).filter((n) => n.length > 0));
150
+ return data.productICPs.every((icp) => !icp.productId || productNames.has(icp.productId));
151
+ }, {
152
+ message: "productICPs[].productId must reference an existing products[].name",
153
+ path: ["productICPs"],
154
+ });
155
+ // ─── Document schema (unchanged) ───────────────────────────────────────────
95
156
  const DOCUMENT_CATEGORIES = [
96
157
  "PRODUCT_CATALOG",
97
158
  "CONGRESS_LIST",
@@ -101,9 +162,10 @@ const DOCUMENT_CATEGORIES = [
101
162
  "CAMPAIGN_BRIEF",
102
163
  "OTHER",
103
164
  ];
165
+ // ─── Common descriptions ───────────────────────────────────────────────────
104
166
  const ORG_ID_DESC = "Optional Clerk organization ID to target a specific org's data room. " +
105
167
  "Defaults to the caller's own org. Requires admin access for writes.";
106
- const DRY_RUN_DESC = "If true, validate the patch + return the merged preview WITHOUT writing to the API. " +
168
+ const DRY_RUN_DESC = "If true, validate the merged payload + return the preview WITHOUT writing. " +
107
169
  "Use to dry-fit a change before persisting.";
108
170
  async function fetchDataRoom(organizationId) {
109
171
  const params = organizationId
@@ -112,47 +174,73 @@ async function fetchDataRoom(organizationId) {
112
174
  const data = (await getClient().get("/api/data-room", params));
113
175
  return data ?? {};
114
176
  }
177
+ function currentCompanyInfo(state) {
178
+ const ci = state.companyInfo;
179
+ if (!ci || typeof ci !== "object" || Array.isArray(ci))
180
+ return {};
181
+ return ci;
182
+ }
115
183
  async function putCompanyInfo(companyInfo, organizationId) {
116
184
  const body = { companyInfo };
117
185
  if (organizationId)
118
186
  body.organizationId = organizationId;
119
187
  return getClient().put("/api/data-room", body);
120
188
  }
121
- function currentCompanyInfo(state) {
122
- const ci = state.companyInfo;
123
- return ci && typeof ci === "object" && !Array.isArray(ci)
124
- ? ci
125
- : {};
189
+ // Validate MCP-side against the mirrored v2 schema and surface ALL issues
190
+ // at once (not just the first). Returns null if value passes, or a tool
191
+ // error otherwise.
192
+ function validateOrError(value, hint) {
193
+ const result = companyInfoV2Schema.safeParse(value);
194
+ if (result.success)
195
+ return null;
196
+ const issues = result.error.issues.map((i) => ({
197
+ path: i.path.map(String).join("."),
198
+ message: i.message,
199
+ code: i.code,
200
+ }));
201
+ return toolError(`MCP validation failed against companyInfo v2 schema (${issues.length} issue${issues.length === 1 ? "" : "s"}). ${hint}`, { issues });
126
202
  }
127
- function dryRunResult(patch, mergedCompanyInfo) {
203
+ // Assert that the existing companyInfo is v2-valid. Granular patch tools
204
+ // require a bootstrapped v2 payload to work — otherwise the merged result
205
+ // will fail v2 validation server-side, since the backend replaces wholesale.
206
+ function requireV2Bootstrap(ci) {
207
+ if (ci.schemaVersion !== COMPANY_INFO_SCHEMA_VERSION) {
208
+ return toolError(`Data room companyInfo is not v${COMPANY_INFO_SCHEMA_VERSION} (got schemaVersion=${JSON.stringify(ci.schemaVersion)}). ` +
209
+ "Bootstrap with update_data_room (passing a full v2 payload) before using granular patch tools. " +
210
+ "See describe_company_info_schema for the contract.");
211
+ }
212
+ return null;
213
+ }
214
+ function dryRunResult(merged) {
128
215
  return toolResult({
129
216
  ok: true,
130
217
  dry_run: true,
131
- patch,
132
- preview_company_info: mergedCompanyInfo,
218
+ preview_company_info: merged,
133
219
  note: "No write performed. Re-call without dry_run to persist.",
134
220
  });
135
221
  }
136
- // Validate a sub-schema MCP-side BEFORE calling the API. Returns a normalized
137
- // error result if invalid (collecting EVERY zod issue, not just the first),
138
- // or null if the value is OK to send.
139
- function validateOrError(schema, value, pathPrefix) {
140
- const result = schema.safeParse(value);
141
- if (result.success)
142
- return null;
143
- const issues = result.error.issues.map((i) => ({
144
- path: [pathPrefix, ...i.path.map(String)].filter(Boolean).join("."),
145
- message: i.message,
146
- code: i.code,
147
- }));
148
- return toolError(`Validation failed (${issues.length} issue${issues.length === 1 ? "" : "s"}). ` +
149
- "Fix all listed paths and retry — backend constraints mirrored in MCP.", { issues });
222
+ // Run the full pipeline for a granular tool: fetch current, mutate via the
223
+ // caller-provided patcher, validate, dry-run or persist.
224
+ async function mergeAndPut(organizationId, dry_run, patcher, validationHint) {
225
+ const current = await fetchDataRoom(organizationId);
226
+ const ci = currentCompanyInfo(current);
227
+ const bootstrapErr = requireV2Bootstrap(ci);
228
+ if (bootstrapErr)
229
+ return bootstrapErr;
230
+ const merged = patcher(ci);
231
+ const validationErr = validateOrError(merged, validationHint);
232
+ if (validationErr)
233
+ return validationErr;
234
+ if (dry_run)
235
+ return dryRunResult(merged);
236
+ const data = await putCompanyInfo(merged, organizationId);
237
+ return toolResult(data);
150
238
  }
151
239
  // ─── Tool registrations ────────────────────────────────────────────────────
152
240
  export function registerDataRoomTools(server) {
153
241
  // ── get_data_room ──────────────────────────────────────────────────────
154
- server.tool("get_data_room", "Retrieve the organization's full data room: company info, all documents, and every " +
155
- "persona defined for the workspace. Use this as the entry point when you need to " +
242
+ server.tool("get_data_room", "Retrieve the organization's full data room: company info (v2 schema), all documents, and " +
243
+ "every persona defined for the workspace. Use this as the entry point when you need to " +
156
244
  "understand the company's context before writing campaigns or messages.", {
157
245
  organization_id: z.string().optional().describe(ORG_ID_DESC),
158
246
  }, async ({ organization_id }) => {
@@ -165,87 +253,95 @@ export function registerDataRoomTools(server) {
165
253
  }
166
254
  });
167
255
  // ── describe_company_info_schema ───────────────────────────────────────
168
- // Read-only introspection. Lets agents fetch the contract without
169
- // shooting in the dark or learning by 422.
170
- server.tool("describe_company_info_schema", "Return the companyInfo schema documented at the MCP layer: known fields, enums, " +
171
- "max-length constraints, and which keys pass through to the backend untouched. " +
172
- "Call this BEFORE the first update of a session to know what the API will accept. " +
173
- "The mirror is a subset of the backend schema — fields not listed here still pass " +
174
- "through, but their constraints are enforced server-side and may surface as 422s.", {}, async () => {
256
+ server.tool("describe_company_info_schema", "Return the companyInfo v2 schema mirrored at the MCP layer: all sections, enums, " +
257
+ "max-length caps, required fields, and which patch tool owns each section. Call this " +
258
+ "BEFORE the first update of a session to know what the API will accept. The mirror " +
259
+ "tracks apps/server/src/lib/data-room-schemas.ts in the Leadify backend.", {}, async () => {
175
260
  try {
176
261
  return toolResult({
177
- source: "MCP-mirrored subset of the backend companyInfo schema.",
178
- known_fields: {
179
- name: { type: "string" },
180
- sector: { type: "string" },
181
- description: { type: "string" },
182
- size: { type: "string", note: "Free-form (e.g. '10-50', '200+')." },
183
- website: { type: "string", format: "url" },
184
- valueProposition: { type: "string" },
185
- competitors: { type: "array<string>" },
186
- targetRegions: { type: "array<string>" },
187
- sender: {
188
- type: "object",
189
- shape: {
190
- name: { type: "string", required: true },
191
- title: { type: "string" },
192
- email: { type: "string", format: "email" },
193
- },
262
+ schemaVersion: COMPANY_INFO_SCHEMA_VERSION,
263
+ source: "Mirror of apps/server/src/lib/data-room-schemas.ts in github.com/AgifyAI/leadify.",
264
+ enums: {
265
+ stage: STAGES,
266
+ product_category: PRODUCT_CATEGORIES,
267
+ ce_class: CE_CLASSES,
268
+ reimbursement_status: REIMBURSEMENT_STATUSES,
269
+ pricing_model: PRICING_MODELS,
270
+ currency: CURRENCIES,
271
+ constraint_type: CONSTRAINT_TYPES,
272
+ geo_region: GEO_REGIONS,
273
+ },
274
+ char_limits: CHAR_LIMITS,
275
+ hard_limits: {
276
+ products_max: PRODUCT_HARD_LIMIT,
277
+ productICPs_max: PRODUCT_HARD_LIMIT,
278
+ constraints_max: 10,
279
+ topUseCases_max_per_product: 3,
280
+ differentiators_max_per_product: 3,
281
+ triggers_max: 5,
282
+ coDeciders_max: 5,
283
+ keyMetrics_max: 3,
284
+ miniStories_max: 2,
285
+ },
286
+ sections: {
287
+ identity: {
288
+ fields: ["name (required, ≤200)", "website (optional, ≤500)"],
289
+ patch_tool: "update_company_info_identity",
290
+ },
291
+ snapshot: {
292
+ fields: [
293
+ "pitchOneLiner (required, ≤200)",
294
+ "marketSpecialty (required, ≤100)",
295
+ "stage (required, enum)",
296
+ "salesTeamSizeFR (required, int ≥0)",
297
+ "geo {regions[], countriesIncluded[2-letter codes], countriesExcluded[2-letter codes]}",
298
+ "constraints[] (max 10, see update_company_info_constraint)",
299
+ ],
300
+ patch_tool: "update_company_info_snapshot",
194
301
  },
195
302
  products: {
196
- type: "array<object>",
197
- item_shape: {
198
- name: { type: "string", required: true },
199
- category: {
200
- type: "enum",
201
- values: PRODUCT_CATEGORIES,
202
- required: true,
203
- },
204
- description: { type: "string" },
205
- valueProposition: { type: "string" },
206
- targetCustomer: { type: "string" },
207
- pricing: { type: "string" },
208
- },
303
+ note: "Up to 5 products. Each: {name ≤100, category enum, categoryDetail? ≤150, outcomeUser ≤200, outcomeBuyer ≤200, topUseCases[≤3, ≤150 each], differentiators[≤3, ≤150 each], notFor ≤200, regulatory {ceMarked, ceClass nullable enum, reimbursementStatus enum, aoRequired}}.",
209
304
  patch_tool: "update_company_info_product",
210
305
  },
211
- snapshot: {
212
- type: "object",
213
- shape: {
214
- stage: { type: "enum", values: STAGE_VALUES },
215
- pitchOneLiner: { type: "string", max_length: 200 },
216
- constraints: {
217
- type: "array<object>",
218
- item_shape: {
219
- type: {
220
- type: "enum",
221
- values: CONSTRAINT_TYPES,
222
- required: true,
223
- },
224
- note: {
225
- type: "string",
226
- max_length: 150,
227
- required: true,
228
- },
229
- },
230
- patch_tool: "update_company_info_constraint",
231
- },
232
- },
233
- patch_tool: "update_company_info_snapshot",
306
+ economics: {
307
+ fields: [
308
+ "pricingModel (required, enum)",
309
+ "ticketRange {min, max, currency} (min ≤ max)",
310
+ "salesCycleMonths {min, max} (min max)",
311
+ "triggers[] (max 5, each ≤150)",
312
+ "defaultBaseline (required, ≤200)",
313
+ ],
314
+ patch_tool: "update_company_info_economics",
315
+ },
316
+ productICPs: {
317
+ note: "Up to 5 ICPs. Each: {productId (must reference products[].name), establishmentType ≤150, technicalPrereqs ≤200, roles {champion ≤150, budgetDecider ≤150, coDeciders[≤5, ≤100 each]}}.",
318
+ patch_tool: "update_company_info_product_icp",
234
319
  },
320
+ proofWording: {
321
+ fields: [
322
+ "keyMetrics[] (max 3, each ≤150)",
323
+ "miniStories[] (max 2, each ≤300)",
324
+ "forbiddenWords[] (each ≤100)",
325
+ ],
326
+ patch_tool: "update_company_info_proof_wording",
327
+ },
328
+ },
329
+ escape_hatch: {
330
+ tool: "update_data_room",
331
+ note: "WHOLESALE replacement. Required for first-time bootstrap (no existing v2 payload).",
235
332
  },
236
- patch_tools: [
237
- "update_company_info_identity",
238
- "update_company_info_sender",
239
- "update_company_info_geo",
240
- "update_company_info_snapshot",
241
- "update_company_info_product",
242
- "update_company_info_constraint",
333
+ cross_field_invariants: [
334
+ "productICPs[].productId must equal one of products[].name (or be empty).",
335
+ "economics.ticketRange.min ≤ economics.ticketRange.max.",
336
+ "economics.salesCycleMonths.min ≤ economics.salesCycleMonths.max.",
243
337
  ],
244
- escape_hatch: "update_data_room",
338
+ error_format: {
339
+ shape: "{ error, path, issues: ZodIssue[] } — issues[] contains EVERY validation failure at once.",
340
+ note: "Backend now returns all issues simultaneously. Fix all listed paths before retrying.",
341
+ },
245
342
  notes: [
246
- "Unknown keys pass through to the backend (zod passthrough on companyInfo).",
247
- "Backend currently surfaces ONE zod issue per 422 see TODO in MCP source.",
248
- "Prefer the granular patch tools over update_data_room to avoid wiping nested blocks.",
343
+ "Backend replaces companyInfo wholesale on PUT. Granular tools fetch + merge + send the FULL v2 payload back.",
344
+ "Granular tools require a v2-bootstrapped data room. Call update_data_room with a full v2 payload first if schemaVersion is absent or older.",
249
345
  ],
250
346
  });
251
347
  }
@@ -254,29 +350,26 @@ export function registerDataRoomTools(server) {
254
350
  }
255
351
  });
256
352
  // ── update_data_room ───────────────────────────────────────────────────
257
- // Escape hatch: full companyInfo replacement (top-level keys you pass).
258
- // Validates against the MCP-mirrored schema BEFORE hitting the API so
259
- // callers don't burn round-trips on enum / max-length mistakes.
260
- server.tool("update_data_room", "WHOLESALE update of companyInfo (ESCAPE HATCH). For partial edits, ALWAYS prefer the " +
261
- "granular update_company_info_* tools they do safe read-modify-write merges. Top-level " +
262
- "keys you pass here REPLACE their server-side counterparts (so passing snapshot={stage:'early'} " +
263
- "wipes pitchOneLiner and constraints). MCP validates the payload against the mirrored schema " +
264
- "before sending; backend may still reject unknown constraints with a 422.", {
353
+ // Escape hatch: WHOLESALE replacement. The only path for first-time
354
+ // bootstrap (no existing v2 companyInfo). Validates against v2 before
355
+ // hitting the API.
356
+ server.tool("update_data_room", "WHOLESALE update of companyInfo (ESCAPE HATCH). REPLACES the entire companyInfo with the " +
357
+ "payload you pass. Required for first-time bootstrap. For incremental edits on an " +
358
+ "existing v2 payload, ALWAYS prefer the granular update_company_info_* tools they " +
359
+ "fetch + merge + write the full v2 payload back, so they can't accidentally drop " +
360
+ "required sections. MCP validates against the v2 schema before sending.", {
265
361
  company_info: z
266
362
  .record(z.unknown())
267
- .describe("companyInfo payload. Known fields are validated against the mirrored schema " +
268
- "(see describe_company_info_schema). Unknown fields pass through."),
363
+ .describe("Full v2 companyInfo payload (replaces the existing one). See describe_company_info_schema for the contract."),
269
364
  organization_id: z.string().optional().describe(ORG_ID_DESC),
270
365
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
271
366
  }, async ({ company_info, organization_id, dry_run }) => {
272
367
  try {
273
- const validationError = validateOrError(companyInfoSchema, company_info, "company_info");
274
- if (validationError)
275
- return validationError;
368
+ const validationErr = validateOrError(company_info, "Pass a complete v2 payload — see describe_company_info_schema for the required fields.");
369
+ if (validationErr)
370
+ return validationErr;
276
371
  if (dry_run) {
277
- const current = await fetchDataRoom(organization_id);
278
- const merged = { ...currentCompanyInfo(current), ...company_info };
279
- return dryRunResult(company_info, merged);
372
+ return dryRunResult(company_info);
280
373
  }
281
374
  const data = await putCompanyInfo(company_info, organization_id);
282
375
  return toolResult(data);
@@ -286,195 +379,180 @@ export function registerDataRoomTools(server) {
286
379
  }
287
380
  });
288
381
  // ── update_company_info_identity ───────────────────────────────────────
289
- server.tool("update_company_info_identity", "Patch scalar identity fields of companyInfo (name, sector, description, size, " +
290
- "website, valueProposition). Only the fields you pass are updated. Other companyInfo " +
291
- "blocks (sender, products, snapshot, etc.) remain intact.", {
292
- name: z.string().optional().describe("Company name."),
293
- sector: z.string().optional().describe("Sector / industry."),
294
- description: z.string().optional().describe("Company description."),
295
- size: z.string().optional().describe("Company size (e.g. '10-50', '200+')."),
296
- website: z.string().url().optional().describe("Company website URL."),
297
- valueProposition: z.string().optional().describe("Top-level value proposition."),
298
- organization_id: z.string().optional().describe(ORG_ID_DESC),
299
- dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
300
- }, async ({ name, sector, description, size, website, valueProposition, organization_id, dry_run, }) => {
301
- try {
302
- const patch = {};
303
- if (name !== undefined)
304
- patch.name = name;
305
- if (sector !== undefined)
306
- patch.sector = sector;
307
- if (description !== undefined)
308
- patch.description = description;
309
- if (size !== undefined)
310
- patch.size = size;
311
- if (website !== undefined)
312
- patch.website = website;
313
- if (valueProposition !== undefined)
314
- patch.valueProposition = valueProposition;
315
- if (Object.keys(patch).length === 0) {
316
- return toolError("Provide at least one of: name, sector, description, size, website, valueProposition.");
317
- }
318
- const current = await fetchDataRoom(organization_id);
319
- const merged = { ...currentCompanyInfo(current), ...patch };
320
- if (dry_run)
321
- return dryRunResult(patch, merged);
322
- const data = await putCompanyInfo(patch, organization_id);
323
- return toolResult(data);
324
- }
325
- catch (error) {
326
- return handleToolError(error);
327
- }
328
- });
329
- // ── update_company_info_sender ─────────────────────────────────────────
330
- server.tool("update_company_info_sender", "Patch the sender block (default SDR identity). Reads current sender, shallow-merges " +
331
- "the fields you pass, writes the whole sender object back. Pass set_to_null=true to " +
332
- "clear the sender entirely.", {
333
- name: z.string().optional().describe("Sender display name."),
334
- title: z.string().optional().describe("Sender title / role."),
335
- email: z.string().email().optional().describe("Sender email address."),
336
- set_to_null: z
337
- .boolean()
382
+ server.tool("update_company_info_identity", "Patch the top-level identity fields of companyInfo (name, website). Reads the current " +
383
+ "v2 payload, merges your patch, writes the full v2 payload back. Other sections " +
384
+ "(snapshot, products, economics, productICPs, proofWording) remain intact.", {
385
+ name: z
386
+ .string()
387
+ .max(CHAR_LIMITS.name)
388
+ .optional()
389
+ .describe("Company name (max 200)."),
390
+ website: z
391
+ .string()
392
+ .max(CHAR_LIMITS.website)
338
393
  .optional()
339
- .describe("If true, clears the sender block entirely (mutually exclusive with name/title/email)."),
394
+ .describe("Company website URL (max 500)."),
340
395
  organization_id: z.string().optional().describe(ORG_ID_DESC),
341
396
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
342
- }, async ({ name, title, email, set_to_null, organization_id, dry_run }) => {
397
+ }, async ({ name, website, organization_id, dry_run }) => {
343
398
  try {
344
- const hasFields = name !== undefined || title !== undefined || email !== undefined;
345
- if (!hasFields && !set_to_null) {
346
- return toolError("Provide at least one of: name, title, email, set_to_null.");
347
- }
348
- if (set_to_null && hasFields) {
349
- return toolError("'set_to_null' is mutually exclusive with name/title/email.");
350
- }
351
- const current = await fetchDataRoom(organization_id);
352
- const ci = currentCompanyInfo(current);
353
- const existingSender = ci.sender && typeof ci.sender === "object" && !Array.isArray(ci.sender)
354
- ? ci.sender
355
- : {};
356
- let nextSender;
357
- if (set_to_null) {
358
- nextSender = null;
399
+ if (name === undefined && website === undefined) {
400
+ return toolError("Provide at least one of: name, website.");
359
401
  }
360
- else {
361
- nextSender = { ...existingSender };
402
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
403
+ const out = { ...ci };
362
404
  if (name !== undefined)
363
- nextSender.name = name;
364
- if (title !== undefined)
365
- nextSender.title = title;
366
- if (email !== undefined)
367
- nextSender.email = email;
368
- const validationError = validateOrError(senderSchema, nextSender, "sender");
369
- if (validationError)
370
- return validationError;
371
- }
372
- const patch = { sender: nextSender };
373
- const merged = { ...ci, ...patch };
374
- if (dry_run)
375
- return dryRunResult(patch, merged);
376
- const data = await putCompanyInfo(patch, organization_id);
377
- return toolResult(data);
405
+ out.name = name;
406
+ if (website !== undefined)
407
+ out.website = website;
408
+ return out;
409
+ }, "Identity patch failed against v2 schema.");
378
410
  }
379
411
  catch (error) {
380
412
  return handleToolError(error);
381
413
  }
382
414
  });
383
- // ── update_company_info_geo ────────────────────────────────────────────
384
- server.tool("update_company_info_geo", "Patch geo / competitive lists: targetRegions, competitors. Use 'replace' to overwrite " +
385
- "wholesale, or 'add'/'remove' for incremental edits (deduped). Other companyInfo " +
386
- "fields remain intact.", {
387
- field: z
388
- .enum(["targetRegions", "competitors"])
389
- .describe("Which list to patch."),
390
- action: z
391
- .enum(["replace", "add", "remove"])
392
- .describe("replace = overwrite wholesale; add = append (deduped); remove = drop entries."),
393
- values: z
394
- .array(z.string())
395
- .min(1)
396
- .describe("Values for the action. Must be non-empty."),
415
+ // ── update_company_info_snapshot ───────────────────────────────────────
416
+ server.tool("update_company_info_snapshot", "Patch the snapshot block. Reads the current v2 payload, shallow-merges the snapshot " +
417
+ "fields you pass, writes the full v2 payload back. geo is merged shallowly — pass any " +
418
+ "subset of regions/countriesIncluded/countriesExcluded. constraints[] is REPLACED " +
419
+ "wholesale when passed — use update_company_info_constraint for per-item edits.", {
420
+ pitchOneLiner: z
421
+ .string()
422
+ .max(CHAR_LIMITS.pitchOneLiner)
423
+ .optional()
424
+ .describe("One-liner pitch (max 200)."),
425
+ marketSpecialty: z
426
+ .string()
427
+ .max(CHAR_LIMITS.marketSpecialty)
428
+ .optional()
429
+ .describe("Market specialty (max 100)."),
430
+ stage: z
431
+ .enum(STAGES)
432
+ .optional()
433
+ .describe("Company stage enum: early | growth | scaling | enterprise."),
434
+ salesTeamSizeFR: z
435
+ .number()
436
+ .int()
437
+ .min(0)
438
+ .optional()
439
+ .describe("Sales team size in FR (non-negative integer)."),
440
+ geo: z
441
+ .object({
442
+ regions: z.array(z.enum(GEO_REGIONS)).optional(),
443
+ countriesIncluded: z.array(z.string().length(2)).optional(),
444
+ countriesExcluded: z.array(z.string().length(2)).optional(),
445
+ })
446
+ .optional()
447
+ .describe("Geo block (shallow-merged). regions enum: " +
448
+ GEO_REGIONS.join(", ") +
449
+ ". countries: ISO 3166-1 alpha-2 (2 letters)."),
450
+ constraints: z
451
+ .array(constraintSchema)
452
+ .max(10)
453
+ .optional()
454
+ .describe("Replaces constraints[] wholesale. For per-item edits use update_company_info_constraint."),
397
455
  organization_id: z.string().optional().describe(ORG_ID_DESC),
398
456
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
399
- }, async ({ field, action, values, organization_id, dry_run }) => {
457
+ }, async ({ pitchOneLiner, marketSpecialty, stage, salesTeamSizeFR, geo, constraints, organization_id, dry_run, }) => {
400
458
  try {
401
- const current = await fetchDataRoom(organization_id);
402
- const ci = currentCompanyInfo(current);
403
- const existing = Array.isArray(ci[field]) ? ci[field] : [];
404
- let next;
405
- if (action === "replace") {
406
- next = [...new Set(values)];
407
- }
408
- else if (action === "add") {
409
- next = [...new Set([...existing, ...values])];
459
+ if (pitchOneLiner === undefined &&
460
+ marketSpecialty === undefined &&
461
+ stage === undefined &&
462
+ salesTeamSizeFR === undefined &&
463
+ geo === undefined &&
464
+ constraints === undefined) {
465
+ return toolError("Provide at least one of: pitchOneLiner, marketSpecialty, stage, salesTeamSizeFR, geo, constraints.");
410
466
  }
411
- else {
412
- const removeSet = new Set(values);
413
- next = existing.filter((v) => !removeSet.has(v));
414
- }
415
- const patch = { [field]: next };
416
- const merged = { ...ci, ...patch };
417
- if (dry_run)
418
- return dryRunResult(patch, merged);
419
- const data = await putCompanyInfo(patch, organization_id);
420
- return toolResult(data);
467
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
468
+ const existingSnapshot = ci.snapshot && typeof ci.snapshot === "object" && !Array.isArray(ci.snapshot)
469
+ ? ci.snapshot
470
+ : {};
471
+ const nextSnapshot = { ...existingSnapshot };
472
+ if (pitchOneLiner !== undefined)
473
+ nextSnapshot.pitchOneLiner = pitchOneLiner;
474
+ if (marketSpecialty !== undefined)
475
+ nextSnapshot.marketSpecialty = marketSpecialty;
476
+ if (stage !== undefined)
477
+ nextSnapshot.stage = stage;
478
+ if (salesTeamSizeFR !== undefined)
479
+ nextSnapshot.salesTeamSizeFR = salesTeamSizeFR;
480
+ if (geo !== undefined) {
481
+ const existingGeo = existingSnapshot.geo &&
482
+ typeof existingSnapshot.geo === "object" &&
483
+ !Array.isArray(existingSnapshot.geo)
484
+ ? existingSnapshot.geo
485
+ : {};
486
+ nextSnapshot.geo = { ...existingGeo, ...geo };
487
+ }
488
+ if (constraints !== undefined)
489
+ nextSnapshot.constraints = constraints;
490
+ return { ...ci, snapshot: nextSnapshot };
491
+ }, "Snapshot patch failed against v2 schema.");
421
492
  }
422
493
  catch (error) {
423
494
  return handleToolError(error);
424
495
  }
425
496
  });
426
- // ── update_company_info_snapshot ───────────────────────────────────────
427
- server.tool("update_company_info_snapshot", "Patch the snapshot block. Reads current snapshot, shallow-merges the fields you pass, " +
428
- "writes the whole snapshot back. Constraints[] is REPLACED wholesale when passed — " +
429
- "use update_company_info_constraint for per-item edits. stage and pitchOneLiner are " +
430
- "validated MCP-side (enum + max-length) before the API call.", {
431
- stage: z
432
- .enum(STAGE_VALUES)
433
- .optional()
434
- .describe("Company stage. One of: early, growth, scaling, enterprise."),
435
- pitchOneLiner: z
436
- .string()
437
- .max(200)
438
- .optional()
439
- .describe("One-liner pitch. Max 200 chars."),
440
- constraints: z
441
- .array(constraintSchema)
497
+ // ── update_company_info_constraint ─────────────────────────────────────
498
+ server.tool("update_company_info_constraint", "Add, replace, or remove a single business constraint in snapshot.constraints[] without " +
499
+ "re-sending the whole list. Constraints have no stable identries are addressed by " +
500
+ "zero-based index. Beware: indices shift after a remove. Cap is 10 constraints. " +
501
+ "Each constraint: {label ≤150, type regulatory|brand|legal, note? ≤150}.", {
502
+ action: z
503
+ .enum(["add", "replace", "remove"])
504
+ .describe("add = append; replace = overwrite at index; remove = delete at index."),
505
+ index: z
506
+ .number()
507
+ .int()
508
+ .nonnegative()
442
509
  .optional()
443
- .describe("Full replacement of constraints[]. Use update_company_info_constraint for per-item edits."),
444
- extra: z
445
- .record(z.unknown())
510
+ .describe("Zero-based index. Required for 'replace' and 'remove'."),
511
+ constraint: constraintSchema
446
512
  .optional()
447
- .describe("Escape hatch for additional snapshot keys not yet mirrored in the MCP."),
513
+ .describe("Constraint payload {label ≤150, type regulatory|brand|legal, note? ≤150}. Required for 'add' and 'replace'."),
448
514
  organization_id: z.string().optional().describe(ORG_ID_DESC),
449
515
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
450
- }, async ({ stage, pitchOneLiner, constraints, extra, organization_id, dry_run }) => {
516
+ }, async ({ action, index, constraint, organization_id, dry_run }) => {
451
517
  try {
452
- if (stage === undefined &&
453
- pitchOneLiner === undefined &&
454
- constraints === undefined &&
455
- (extra === undefined || Object.keys(extra).length === 0)) {
456
- return toolError("Provide at least one of: stage, pitchOneLiner, constraints, extra.");
518
+ if ((action === "add" || action === "replace") && !constraint) {
519
+ return toolError(`constraint is required for action '${action}'.`);
457
520
  }
458
- const current = await fetchDataRoom(organization_id);
459
- const ci = currentCompanyInfo(current);
460
- const existingSnapshot = ci.snapshot && typeof ci.snapshot === "object" && !Array.isArray(ci.snapshot)
461
- ? ci.snapshot
462
- : {};
463
- const nextSnapshot = { ...existingSnapshot };
464
- if (stage !== undefined)
465
- nextSnapshot.stage = stage;
466
- if (pitchOneLiner !== undefined)
467
- nextSnapshot.pitchOneLiner = pitchOneLiner;
468
- if (constraints !== undefined)
469
- nextSnapshot.constraints = constraints;
470
- if (extra !== undefined)
471
- Object.assign(nextSnapshot, extra);
472
- const patch = { snapshot: nextSnapshot };
473
- const merged = { ...ci, ...patch };
474
- if (dry_run)
475
- return dryRunResult(patch, merged);
476
- const data = await putCompanyInfo(patch, organization_id);
477
- return toolResult(data);
521
+ if ((action === "replace" || action === "remove") && index === undefined) {
522
+ return toolError(`index is required for action '${action}'.`);
523
+ }
524
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
525
+ const existingSnapshot = ci.snapshot && typeof ci.snapshot === "object" && !Array.isArray(ci.snapshot)
526
+ ? ci.snapshot
527
+ : {};
528
+ const list = Array.isArray(existingSnapshot.constraints)
529
+ ? existingSnapshot.constraints.map((c) => ({
530
+ ...c,
531
+ }))
532
+ : [];
533
+ if (action === "add") {
534
+ if (list.length >= 10) {
535
+ throw new Error("Constraints list is at cap (10). Remove one before adding.");
536
+ }
537
+ list.push(constraint);
538
+ }
539
+ else if (action === "replace") {
540
+ if (index < 0 || index >= list.length) {
541
+ throw new Error(`Index ${index} out of bounds (constraints list has ${list.length} entries).`);
542
+ }
543
+ list[index] = constraint;
544
+ }
545
+ else {
546
+ if (index < 0 || index >= list.length) {
547
+ throw new Error(`Index ${index} out of bounds (constraints list has ${list.length} entries).`);
548
+ }
549
+ list.splice(index, 1);
550
+ }
551
+ return {
552
+ ...ci,
553
+ snapshot: { ...existingSnapshot, constraints: list },
554
+ };
555
+ }, "Constraint patch failed against v2 schema.");
478
556
  }
479
557
  catch (error) {
480
558
  return handleToolError(error);
@@ -482,19 +560,22 @@ export function registerDataRoomTools(server) {
482
560
  });
483
561
  // ── update_company_info_product ────────────────────────────────────────
484
562
  server.tool("update_company_info_product", "Add, replace, or remove a single product in companyInfo.products[] without re-sending " +
485
- "the whole array. Lookups are by product `name` (case-sensitive exact match). Reads " +
486
- "the current products[], mutates the matched entry, writes the full array back. " +
487
- "category is a strict enum (saas|medical_device|service|mixed) validated MCP-side.", {
563
+ "the whole array. Lookups by product `name` (case-sensitive exact match). Cap is 5 " +
564
+ "products. Each product: {name ≤100, category enum, categoryDetail? ≤150, outcomeUser " +
565
+ "≤200, outcomeBuyer ≤200, topUseCases[≤3, ≤150 each], differentiators[≤3, ≤150 each], " +
566
+ "notFor ≤200, regulatory {ceMarked, ceClass nullable, reimbursementStatus enum, aoRequired}}. " +
567
+ "WARNING: if a productICP.productId references this product's name, removing/renaming " +
568
+ "will fail the cross-field refine — fix the ICP first.", {
488
569
  action: z
489
570
  .enum(["add", "replace", "remove"])
490
- .describe("add = append (fails if name already exists); replace = overwrite by name; remove = delete by name."),
571
+ .describe("add = append (fails if name exists or list at cap 5); replace = overwrite by name; remove = delete by name."),
491
572
  name: z
492
573
  .string()
493
574
  .optional()
494
575
  .describe("Lookup key for replace/remove. Required for those actions. For 'add' the name comes from the product payload."),
495
576
  product: productSchema
496
577
  .optional()
497
- .describe("Product payload. Required for 'add' and 'replace'. Shape: {name, category, description?, valueProposition?, targetCustomer?, pricing?}."),
578
+ .describe("Product payload. Required for 'add' and 'replace'."),
498
579
  organization_id: z.string().optional().describe(ORG_ID_DESC),
499
580
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
500
581
  }, async ({ action, name, product, organization_id, dry_run }) => {
@@ -505,105 +586,213 @@ export function registerDataRoomTools(server) {
505
586
  if ((action === "replace" || action === "remove") && !name) {
506
587
  return toolError(`name is required for action '${action}'.`);
507
588
  }
508
- const current = await fetchDataRoom(organization_id);
509
- const ci = currentCompanyInfo(current);
510
- const list = Array.isArray(ci.products)
511
- ? ci.products.map((p) => ({ ...p }))
512
- : [];
513
- if (action === "add") {
514
- const newName = product.name;
515
- if (list.some((p) => p.name === newName)) {
516
- return toolError(`A product with name "${newName}" already exists. Use action 'replace' instead.`);
589
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
590
+ const list = Array.isArray(ci.products)
591
+ ? ci.products.map((p) => ({ ...p }))
592
+ : [];
593
+ if (action === "add") {
594
+ const newName = product.name;
595
+ if (list.some((p) => p.name === newName)) {
596
+ throw new Error(`A product with name "${newName}" already exists. Use action 'replace' instead.`);
597
+ }
598
+ if (list.length >= PRODUCT_HARD_LIMIT) {
599
+ throw new Error(`Products list is at cap (${PRODUCT_HARD_LIMIT}). Remove one before adding.`);
600
+ }
601
+ list.push(product);
517
602
  }
518
- list.push(product);
519
- }
520
- else if (action === "replace") {
521
- const idx = list.findIndex((p) => p.name === name);
522
- if (idx === -1) {
523
- return toolError(`No product found with name "${name}".`);
603
+ else if (action === "replace") {
604
+ const idx = list.findIndex((p) => p.name === name);
605
+ if (idx === -1) {
606
+ throw new Error(`No product found with name "${name}".`);
607
+ }
608
+ list[idx] = product;
524
609
  }
525
- list[idx] = product;
526
- }
527
- else {
528
- const before = list.length;
529
- const filtered = list.filter((p) => p.name !== name);
530
- if (filtered.length === before) {
531
- return toolError(`No product found with name "${name}".`);
610
+ else {
611
+ const before = list.length;
612
+ const filtered = list.filter((p) => p.name !== name);
613
+ if (filtered.length === before) {
614
+ throw new Error(`No product found with name "${name}".`);
615
+ }
616
+ list.length = 0;
617
+ list.push(...filtered);
532
618
  }
533
- list.length = 0;
534
- list.push(...filtered);
619
+ return { ...ci, products: list };
620
+ }, "Product patch failed against v2 schema.");
621
+ }
622
+ catch (error) {
623
+ return handleToolError(error);
624
+ }
625
+ });
626
+ // ── update_company_info_economics ──────────────────────────────────────
627
+ server.tool("update_company_info_economics", "Patch the economics block. Reads current economics, shallow-merges your fields, writes " +
628
+ "back. ticketRange and salesCycleMonths are REPLACED wholesale when passed (their " +
629
+ "internal refine enforces min ≤ max). triggers[] is replaced wholesale.", {
630
+ pricingModel: z
631
+ .enum(PRICING_MODELS)
632
+ .optional()
633
+ .describe("Pricing model enum: saas_per_site | saas_per_exam | capex | service | mixed."),
634
+ ticketRange: z
635
+ .object({
636
+ min: z.number().min(0),
637
+ max: z.number().min(0),
638
+ currency: z.enum(CURRENCIES),
639
+ })
640
+ .optional()
641
+ .describe("Replaces ticketRange wholesale. min ≤ max enforced. currency ∈ EUR|USD."),
642
+ salesCycleMonths: z
643
+ .object({
644
+ min: z.number().int().min(0),
645
+ max: z.number().int().min(0),
646
+ })
647
+ .optional()
648
+ .describe("Replaces salesCycleMonths wholesale. min ≤ max enforced."),
649
+ triggers: z
650
+ .array(z.string().max(CHAR_LIMITS.triggerItem))
651
+ .max(5)
652
+ .optional()
653
+ .describe("Replaces triggers[] wholesale (max 5 items, each ≤150)."),
654
+ defaultBaseline: z
655
+ .string()
656
+ .max(CHAR_LIMITS.defaultBaseline)
657
+ .optional()
658
+ .describe("Default baseline (max 200)."),
659
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
660
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
661
+ }, async ({ pricingModel, ticketRange, salesCycleMonths, triggers, defaultBaseline, organization_id, dry_run, }) => {
662
+ try {
663
+ if (pricingModel === undefined &&
664
+ ticketRange === undefined &&
665
+ salesCycleMonths === undefined &&
666
+ triggers === undefined &&
667
+ defaultBaseline === undefined) {
668
+ return toolError("Provide at least one of: pricingModel, ticketRange, salesCycleMonths, triggers, defaultBaseline.");
535
669
  }
536
- const patch = { products: list };
537
- const merged = { ...ci, ...patch };
538
- if (dry_run)
539
- return dryRunResult(patch, merged);
540
- const data = await putCompanyInfo(patch, organization_id);
541
- return toolResult(data);
670
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
671
+ const existing = ci.economics && typeof ci.economics === "object" && !Array.isArray(ci.economics)
672
+ ? ci.economics
673
+ : {};
674
+ const next = { ...existing };
675
+ if (pricingModel !== undefined)
676
+ next.pricingModel = pricingModel;
677
+ if (ticketRange !== undefined)
678
+ next.ticketRange = ticketRange;
679
+ if (salesCycleMonths !== undefined)
680
+ next.salesCycleMonths = salesCycleMonths;
681
+ if (triggers !== undefined)
682
+ next.triggers = triggers;
683
+ if (defaultBaseline !== undefined)
684
+ next.defaultBaseline = defaultBaseline;
685
+ return { ...ci, economics: next };
686
+ }, "Economics patch failed against v2 schema.");
542
687
  }
543
688
  catch (error) {
544
689
  return handleToolError(error);
545
690
  }
546
691
  });
547
- // ── update_company_info_constraint ─────────────────────────────────────
548
- server.tool("update_company_info_constraint", "Add, replace, or remove a single business constraint inside snapshot.constraints[] " +
549
- "without re-sending the whole array. Constraints have no stable id entries are " +
550
- "addressed by zero-based index. Beware: indices shift after a remove, so re-fetch " +
551
- "before chaining edits. type is a strict enum (regulatory|brand|legal), note is " +
552
- "capped at 150 chars — validated MCP-side.", {
692
+ // ── update_company_info_product_icp ────────────────────────────────────
693
+ server.tool("update_company_info_product_icp", "Add, replace, or remove a single product ICP in companyInfo.productICPs[]. Lookups by " +
694
+ "`productId` (must match an existing products[].name). Cap is 5 ICPs. Each ICP: " +
695
+ "{productId, establishmentType ≤150, technicalPrereqs ≤200, roles {champion ≤150, " +
696
+ "budgetDecider ≤150, coDeciders[≤5, ≤100 each]}}.", {
553
697
  action: z
554
698
  .enum(["add", "replace", "remove"])
555
- .describe("add = append; replace = overwrite at index; remove = delete at index."),
556
- index: z
557
- .number()
558
- .int()
559
- .nonnegative()
699
+ .describe("add = append (fails if productId exists or list at cap 5); replace = overwrite by productId; remove = delete by productId."),
700
+ productId: z
701
+ .string()
560
702
  .optional()
561
- .describe("Zero-based index. Required for 'replace' and 'remove'."),
562
- constraint: constraintSchema
703
+ .describe("Lookup key for replace/remove. For 'add' the productId comes from the icp payload."),
704
+ icp: productICPSchema
563
705
  .optional()
564
- .describe("Constraint payload {type ∈ regulatory|brand|legal, note ≤150 chars}. Required for 'add' and 'replace'."),
706
+ .describe("ICP payload. Required for 'add' and 'replace'."),
565
707
  organization_id: z.string().optional().describe(ORG_ID_DESC),
566
708
  dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
567
- }, async ({ action, index, constraint, organization_id, dry_run }) => {
709
+ }, async ({ action, productId, icp, organization_id, dry_run }) => {
568
710
  try {
569
- if ((action === "add" || action === "replace") && !constraint) {
570
- return toolError(`constraint is required for action '${action}'.`);
711
+ if ((action === "add" || action === "replace") && !icp) {
712
+ return toolError(`icp is required for action '${action}'.`);
571
713
  }
572
- if ((action === "replace" || action === "remove") && index === undefined) {
573
- return toolError(`index is required for action '${action}'.`);
574
- }
575
- const current = await fetchDataRoom(organization_id);
576
- const ci = currentCompanyInfo(current);
577
- const existingSnapshot = ci.snapshot && typeof ci.snapshot === "object" && !Array.isArray(ci.snapshot)
578
- ? ci.snapshot
579
- : {};
580
- const list = Array.isArray(existingSnapshot.constraints)
581
- ? existingSnapshot.constraints.map((c) => ({
582
- ...c,
583
- }))
584
- : [];
585
- if (action === "add") {
586
- list.push(constraint);
714
+ if ((action === "replace" || action === "remove") && !productId) {
715
+ return toolError(`productId is required for action '${action}'.`);
587
716
  }
588
- else if (action === "replace") {
589
- if (index < 0 || index >= list.length) {
590
- return toolError(`Index ${index} out of bounds (constraints list has ${list.length} entries).`);
717
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
718
+ const list = Array.isArray(ci.productICPs)
719
+ ? ci.productICPs.map((i) => ({ ...i }))
720
+ : [];
721
+ if (action === "add") {
722
+ const newId = icp.productId;
723
+ if (list.some((i) => i.productId === newId)) {
724
+ throw new Error(`An ICP for productId "${newId}" already exists. Use 'replace' instead.`);
725
+ }
726
+ if (list.length >= PRODUCT_HARD_LIMIT) {
727
+ throw new Error(`ProductICPs list is at cap (${PRODUCT_HARD_LIMIT}). Remove one before adding.`);
728
+ }
729
+ list.push(icp);
591
730
  }
592
- list[index] = constraint;
593
- }
594
- else {
595
- if (index < 0 || index >= list.length) {
596
- return toolError(`Index ${index} out of bounds (constraints list has ${list.length} entries).`);
731
+ else if (action === "replace") {
732
+ const idx = list.findIndex((i) => i.productId === productId);
733
+ if (idx === -1) {
734
+ throw new Error(`No ICP found with productId "${productId}".`);
735
+ }
736
+ list[idx] = icp;
737
+ }
738
+ else {
739
+ const before = list.length;
740
+ const filtered = list.filter((i) => i.productId !== productId);
741
+ if (filtered.length === before) {
742
+ throw new Error(`No ICP found with productId "${productId}".`);
743
+ }
744
+ list.length = 0;
745
+ list.push(...filtered);
597
746
  }
598
- list.splice(index, 1);
747
+ return { ...ci, productICPs: list };
748
+ }, "Product ICP patch failed against v2 schema.");
749
+ }
750
+ catch (error) {
751
+ return handleToolError(error);
752
+ }
753
+ });
754
+ // ── update_company_info_proof_wording ──────────────────────────────────
755
+ server.tool("update_company_info_proof_wording", "Patch the proofWording block (keyMetrics, miniStories, forbiddenWords). Each list is " +
756
+ "REPLACED wholesale when passed. keyMetrics max 3 (≤150 each), miniStories max 2 (≤300 " +
757
+ "each), forbiddenWords unbounded (≤100 each).", {
758
+ keyMetrics: z
759
+ .array(z.string().max(CHAR_LIMITS.keyMetric))
760
+ .max(3)
761
+ .optional()
762
+ .describe("Replaces keyMetrics[] wholesale (max 3, each ≤150)."),
763
+ miniStories: z
764
+ .array(z.string().max(CHAR_LIMITS.miniStory))
765
+ .max(2)
766
+ .optional()
767
+ .describe("Replaces miniStories[] wholesale (max 2, each ≤300)."),
768
+ forbiddenWords: z
769
+ .array(z.string().max(CHAR_LIMITS.forbiddenWord))
770
+ .optional()
771
+ .describe("Replaces forbiddenWords[] wholesale (each ≤100)."),
772
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
773
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
774
+ }, async ({ keyMetrics, miniStories, forbiddenWords, organization_id, dry_run }) => {
775
+ try {
776
+ if (keyMetrics === undefined &&
777
+ miniStories === undefined &&
778
+ forbiddenWords === undefined) {
779
+ return toolError("Provide at least one of: keyMetrics, miniStories, forbiddenWords.");
599
780
  }
600
- const nextSnapshot = { ...existingSnapshot, constraints: list };
601
- const patch = { snapshot: nextSnapshot };
602
- const merged = { ...ci, ...patch };
603
- if (dry_run)
604
- return dryRunResult(patch, merged);
605
- const data = await putCompanyInfo(patch, organization_id);
606
- return toolResult(data);
781
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
782
+ const existing = ci.proofWording &&
783
+ typeof ci.proofWording === "object" &&
784
+ !Array.isArray(ci.proofWording)
785
+ ? ci.proofWording
786
+ : {};
787
+ const next = { ...existing };
788
+ if (keyMetrics !== undefined)
789
+ next.keyMetrics = keyMetrics;
790
+ if (miniStories !== undefined)
791
+ next.miniStories = miniStories;
792
+ if (forbiddenWords !== undefined)
793
+ next.forbiddenWords = forbiddenWords;
794
+ return { ...ci, proofWording: next };
795
+ }, "Proof wording patch failed against v2 schema.");
607
796
  }
608
797
  catch (error) {
609
798
  return handleToolError(error);
@@ -637,20 +826,3 @@ export function registerDataRoomTools(server) {
637
826
  }
638
827
  });
639
828
  }
640
- // ─── Backend TODO ──────────────────────────────────────────────────────────
641
- // The Leadify backend currently runs the data-room companyInfo schema with
642
- // .parse(), so any payload with N validation errors only surfaces the FIRST
643
- // one as a 422. That forces clients (and LLMs) into a death loop of
644
- // "fix one field, retry, discover next error, retry…".
645
- //
646
- // Fix on the backend side (apps/server most likely):
647
- // const result = companyInfoSchema.safeParse(input);
648
- // if (!result.success) {
649
- // return res.status(422).json({
650
- // error: "Validation failed",
651
- // issues: result.error.issues, // ALL of them, not just the first
652
- // });
653
- // }
654
- //
655
- // Once that ships, the MCP can pass the full issue list through verbatim
656
- // instead of relying on the mirror-side validation as a fallback.