@agifyai/leadify-mcp 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,44 @@
1
1
  # Leadify MCP Server
2
2
 
3
- Serveur MCP (Model Context Protocol) pour l'API Leadify. Expose tous les endpoints REST de Leadify sous forme de tools MCP, utilisables depuis Claude Desktop, Claude Code ou tout client compatible MCP.
3
+ Serveur MCP (Model Context Protocol) pour l'API Leadify. Expose les endpoints REST de Leadify sous forme de tools utilisables depuis Claude Desktop, Claude Code, Cursor ou tout client compatible MCP.
4
4
 
5
- ## Installation
5
+ Package npm : [`@agifyai/leadify-mcp`](https://www.npmjs.com/package/@agifyai/leadify-mcp)
6
6
 
7
- Le serveur est publié sur npm sous `@agifyai/leadify-mcp`. Aucun clone, aucun build local : `npx` télécharge la dernière version à chaque démarrage.
7
+ ---
8
+
9
+ ## 📦 Pour les utilisateurs
10
+
11
+ Aucun clone, aucun build. `npx` télécharge la dernière version à chaque démarrage de session MCP.
12
+
13
+ ### Pré-requis
14
+
15
+ - [Node.js](https://nodejs.org/) ≥ 18 (`node --version` pour vérifier)
16
+ - Une clé API Leadify (demander à l'équipe ou la générer dans l'app)
17
+
18
+ ### Claude Code
19
+
20
+ ```bash
21
+ claude mcp add leadify -e LEADIFY_API_KEY=votre-clé-api -- npx -y @agifyai/leadify-mcp@latest
22
+ ```
23
+
24
+ > Le `--` est nécessaire pour que `claude mcp add` ne tente pas d'interpréter le `-y` de `npx` comme une de ses propres options.
25
+
26
+ Vérifier que c'est bien branché :
27
+
28
+ ```bash
29
+ claude mcp list
30
+ ```
31
+
32
+ Tu dois voir `leadify` dans la liste. Dans une session Claude Code, demande "appelle test_api_key" pour valider.
8
33
 
9
34
  ### Claude Desktop
10
35
 
11
36
  Ouvrir le fichier de configuration :
37
+
12
38
  - **macOS** : `~/Library/Application Support/Claude/claude_desktop_config.json`
13
39
  - **Windows** : `%APPDATA%\Claude\claude_desktop_config.json`
14
40
 
15
- Ajouter une entrée `"leadify"` dans `"mcpServers"` :
41
+ Ajouter une entrée `leadify` dans `mcpServers` :
16
42
 
17
43
  ```json
18
44
  {
@@ -28,21 +54,32 @@ Ajouter une entrée `"leadify"` dans `"mcpServers"` :
28
54
  }
29
55
  ```
30
56
 
31
- Redémarrer Claude Desktop. Les tools apparaissent (icône marteau).
57
+ Redémarrer Claude Desktop. Les tools Leadify apparaissent (icône marteau dans la zone de saisie).
32
58
 
33
- ### Claude Code
59
+ ### Mise à jour automatique
60
+
61
+ Le tag `@latest` force `npx` à vérifier la dernière version publiée à chaque lancement de session. Quand un nouveau tool est mergé sur `main` et publié, il est dispo dès la **session suivante** — sans `git pull`, sans rebuild, sans rien.
62
+
63
+ Pour forcer un rafraîchissement immédiat sans attendre le cache npm :
34
64
 
35
65
  ```bash
36
- claude mcp add leadify npx -y @agifyai/leadify-mcp@latest -e LEADIFY_API_KEY=votre-clé-api
66
+ npm cache clean --force
37
67
  ```
38
68
 
39
- ### Mise à jour automatique
69
+ ### Dépannage
40
70
 
41
- Le tag `@latest` force `npx` à vérifier la version publiée à chaque lancement de session MCP. Quand un nouveau tool est mergé sur `main` et publié, il est dispo à la session suivante — pas de `git pull`, pas de rebuild.
71
+ | Symptôme | Cause / solution |
72
+ |---|---|
73
+ | `LEADIFY_API_KEY environment variable is required` | La clé n'est pas passée. Vérifier le `-e` (Claude Code) ou le bloc `env` (Claude Desktop). |
74
+ | `401 Unauthorized` sur tous les tools | Clé API invalide ou révoquée. Tester avec `test_api_key`. |
75
+ | Le serveur ne démarre pas | Vérifier que `npx -y @agifyai/leadify-mcp@latest` tourne en standalone. Si erreur réseau, vérifier l'accès à `registry.npmjs.org`. |
76
+ | Tool ajouté côté équipe mais pas visible chez moi | Quitter complètement le client (Claude Desktop : ⌘Q ; Claude Code : ferme la session) et relancer. |
42
77
 
43
- ## Développement
78
+ ---
44
79
 
45
- Pour bosser sur le MCP en local :
80
+ ## 🛠️ Pour les contributeurs
81
+
82
+ ### Setup local
46
83
 
47
84
  ```bash
48
85
  git clone git@github.com:AgifyAI/mcp_leadify.git
@@ -51,20 +88,91 @@ npm install
51
88
  npm run build
52
89
  ```
53
90
 
54
- Pointer Claude Code sur la build locale :
91
+ Brancher Claude Code sur ta build locale (en plus de la version npm si tu veux comparer) :
92
+
93
+ ```bash
94
+ claude mcp add leadify-dev -e LEADIFY_API_KEY=votre-clé-api -- node /chemin/absolu/vers/mcp_leadify/dist/index.js
95
+ ```
96
+
97
+ Mode watch :
55
98
 
56
99
  ```bash
57
- claude mcp add leadify-dev node /chemin/absolu/vers/mcp_leadify/dist/index.js -e LEADIFY_API_KEY=votre-clé-api
100
+ npm run dev
58
101
  ```
59
102
 
103
+ ### Architecture
104
+
105
+ ```
106
+ src/
107
+ ├── index.ts # entrée stdio (shebang + transport)
108
+ ├── server.ts # création du McpServer + register* de chaque module
109
+ ├── client.ts # LeadifyClient (singleton, lit LEADIFY_API_KEY)
110
+ ├── types.ts # toolResult, handleToolError, LeadifyApiError
111
+ └── tools/
112
+ ├── auth.ts
113
+ ├── leads.ts
114
+ ├── campaigns.ts
115
+ ├── dataroom.ts
116
+ ├── personas.ts
117
+ └── ... # un fichier = un domaine fonctionnel
118
+ ```
119
+
120
+ Un tool = un appel à `server.tool(name, description, zodSchema, async handler)`. Voir `src/tools/auth.ts` pour le plus simple.
121
+
122
+ ### Ajouter un tool
123
+
124
+ 1. Coder le tool dans le fichier de domaine pertinent (`src/tools/<domaine>.ts`), ou créer un nouveau fichier.
125
+ 2. Si nouveau fichier : exporter `registerXxxTools(server)` et l'appeler depuis `src/server.ts`.
126
+ 3. Vérifier que ça compile :
127
+ ```bash
128
+ npm run build
129
+ ```
130
+ 4. Tester en local (cf. setup ci-dessus).
131
+ 5. Bumper la version et publier (cf. section suivante).
132
+
60
133
  ### Publier une nouvelle version
61
134
 
135
+ Le publish est automatique via GitHub Actions sur push `main`. Le workflow ne fait rien si la version dans `package.json` n'a pas bougé — il faut donc bumper avant de push.
136
+
62
137
  ```bash
63
- npm version patch # ou minor / major
138
+ npm version patch # 1.4.2 1.4.3 (bug fix, ajout de tool)
139
+ npm version minor # 1.4.2 → 1.5.0 (changement non-breaking notable)
140
+ npm version major # 1.4.2 → 2.0.0 (breaking : tool renommé / supprimé / signature changée)
64
141
  git push && git push --tags
65
142
  ```
66
143
 
67
- GitHub Actions build et publie sur npm automatiquement (workflow `.github/workflows/publish.yml`). Si la version dans `package.json` n'a pas bougé, le workflow skip pas de doublon.
144
+ `npm version` crée un commit + un tag git automatiquement.
145
+
146
+ Vérifier la publication :
147
+
148
+ ```bash
149
+ npm view @agifyai/leadify-mcp version
150
+ ```
151
+
152
+ Et le run du workflow : https://github.com/AgifyAI/mcp_leadify/actions
153
+
154
+ ### Comment marche la CI
155
+
156
+ `.github/workflows/publish.yml` se déclenche sur push `main` :
157
+
158
+ 1. Checkout + install Node 20.
159
+ 2. `npm ci` pour installer les deps.
160
+ 3. Check si la version actuelle de `package.json` est déjà publiée sur npm. Si oui, skip silencieusement (on évite les doublons et on permet de push des commits non-version sur `main`).
161
+ 4. Sinon, upgrade npm vers ≥ 11.5.1 (requis pour Trusted Publishing) puis `npm publish`.
162
+
163
+ L'authentification npm passe par **Trusted Publishing (OIDC)** : pas de token stocké, GitHub Actions s'authentifie directement auprès de npm via la permission `id-token: write` du workflow. La trust relation est configurée sur la [page npm du package](https://www.npmjs.com/package/@agifyai/leadify-mcp/access) (Trusted Publisher : `AgifyAI/mcp_leadify` / `publish.yml`).
164
+
165
+ Conséquences pratiques :
166
+ - Pas de secret `NPM_TOKEN` à rotater.
167
+ - Le workflow ne peut publier que depuis ce repo + ce fichier de workflow exact. Renommer `publish.yml` casse la trust → mettre à jour côté npm si besoin.
168
+
169
+ ### Conventions
170
+
171
+ - **Versionning** : suivre semver. Ajout de tool = `patch` (rétrocompatible). Renommage / suppression / signature breaking = `major`.
172
+ - **Description des tools** : verbeuse et précise — c'est ce que le modèle lit pour décider d'utiliser le tool. Voir les tools `outreach_*` pour des exemples détaillés.
173
+ - **Erreurs** : toujours wrapper le handler dans `try / catch` et retourner `handleToolError(error)` en cas d'échec — ça normalise les erreurs API en réponse MCP propre.
174
+
175
+ ---
68
176
 
69
177
  ## Tools disponibles
70
178
 
@@ -89,8 +197,16 @@ GitHub Actions build et publie sur npm automatiquement (workflow `.github/workfl
89
197
  | `export_campaign` | Exporter les statistiques complètes d'une campagne en CSV. |
90
198
  | `add_signal` | Ajouter un signal de business intelligence à un lead (INFO, CRITICAL, GOLDEN). |
91
199
  | `add_activity` | Journaliser une interaction prospect (LinkedIn, email, call) dans le feed du lead. |
92
- | `get_data_room` | Récupérer la data room complète : infos société, documents, personas. |
93
- | `update_data_room` | Mettre à jour les informations société (companyInfo) de la data room. |
200
+ | `get_data_room` | Récupérer la data room complète : infos société (v2), documents, personas. |
201
+ | `describe_company_info_schema` | Lire le schéma companyInfo v2 mirroré côté MCP (enums, max-lengths, sections, patch tool par section). À appeler avant la première update. |
202
+ | `update_data_room` | Update WHOLESALE (escape hatch) — remplace l'intégralité de companyInfo. Requis pour le bootstrap initial. Préférer les tools granulaires pour l'incrémental. |
203
+ | `update_company_info_identity` | Patch name + website. Reads + merges + PUTs le full v2 payload. |
204
+ | `update_company_info_snapshot` | Patch du bloc snapshot (pitchOneLiner, marketSpecialty, stage, salesTeamSizeFR, geo, constraints). geo merge shallow. |
205
+ | `update_company_info_constraint` | Add / replace / remove une seule contrainte dans snapshot.constraints[]. label + type enum + note≤150. Cap 10. |
206
+ | `update_company_info_product` | Add / replace / remove un seul produit dans products[]. category enum strict + regulatory sub-block. Cap 5. |
207
+ | `update_company_info_economics` | Patch du bloc economics (pricingModel, ticketRange, salesCycleMonths, triggers, defaultBaseline). |
208
+ | `update_company_info_product_icp` | Add / replace / remove un seul ICP dans productICPs[]. Lookup par productId (must match a products[].name). Cap 5. |
209
+ | `update_company_info_proof_wording` | Patch du bloc proofWording (keyMetrics, miniStories, forbiddenWords). |
94
210
  | `add_data_room_document` | Ajouter un document textuel à la data room avec catégorie. |
95
211
  | `upsert_persona` | Créer ou mettre à jour un persona (ciblage, messaging, outils actifs). |
96
212
  | `get_persona` | Récupérer un persona par son ID. |
@@ -107,11 +223,3 @@ GitHub Actions build et publie sur npm automatiquement (workflow `.github/workfl
107
223
  | `update_outreach_case_study` | Ajouter / remplacer / supprimer un case study par index, sans re-envoyer la liste. |
108
224
  | `update_outreach_urls` | Patch booking_url et/ou website_url uniquement. |
109
225
  | `set_outreach_connection_request` | Toggle du flag connectionRequestEnabled (LinkedIn invite vs cold DM). |
110
-
111
- ## Développement
112
-
113
- Mode watch pour le développement :
114
-
115
- ```bash
116
- npm run dev
117
- ```
@@ -1,6 +1,158 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
- import { toolResult, handleToolError } from "../types.js";
3
+ import { toolResult, toolError, handleToolError } from "../types.js";
4
+ // ─── Mirror of the backend companyInfo v2 schema ───────────────────────────
5
+ //
6
+ // SOURCE OF TRUTH:
7
+ // github.com/AgifyAI/leadify → apps/server/src/lib/data-room-schemas.ts
8
+ //
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.
13
+ //
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"];
19
+ const PRODUCT_CATEGORIES = ["saas", "medical_device", "service", "mixed"];
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"];
24
+ const CONSTRAINT_TYPES = ["regulatory", "brand", "legal"];
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
98
+ .object({
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
138
+ .object({
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,
147
+ })
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) ───────────────────────────────────────────
4
156
  const DOCUMENT_CATEGORIES = [
5
157
  "PRODUCT_CATALOG",
6
158
  "CONGRESS_LIST",
@@ -10,48 +162,642 @@ const DOCUMENT_CATEGORIES = [
10
162
  "CAMPAIGN_BRIEF",
11
163
  "OTHER",
12
164
  ];
165
+ // ─── Common descriptions ───────────────────────────────────────────────────
13
166
  const ORG_ID_DESC = "Optional Clerk organization ID to target a specific org's data room. " +
14
167
  "Defaults to the caller's own org. Requires admin access for writes.";
168
+ const DRY_RUN_DESC = "If true, validate the merged payload + return the preview WITHOUT writing. " +
169
+ "Use to dry-fit a change before persisting.";
170
+ async function fetchDataRoom(organizationId) {
171
+ const params = organizationId
172
+ ? new URLSearchParams({ organizationId })
173
+ : undefined;
174
+ const data = (await getClient().get("/api/data-room", params));
175
+ return data ?? {};
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
+ }
183
+ async function putCompanyInfo(companyInfo, organizationId) {
184
+ const body = { companyInfo };
185
+ if (organizationId)
186
+ body.organizationId = organizationId;
187
+ return getClient().put("/api/data-room", body);
188
+ }
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 });
202
+ }
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) {
215
+ return toolResult({
216
+ ok: true,
217
+ dry_run: true,
218
+ preview_company_info: merged,
219
+ note: "No write performed. Re-call without dry_run to persist.",
220
+ });
221
+ }
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);
238
+ }
239
+ // ─── Tool registrations ────────────────────────────────────────────────────
15
240
  export function registerDataRoomTools(server) {
16
241
  // ── get_data_room ──────────────────────────────────────────────────────
17
- server.tool("get_data_room", "Retrieve the organization's full data room: company info, all documents, and every " +
18
- "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 " +
19
244
  "understand the company's context before writing campaigns or messages.", {
20
245
  organization_id: z.string().optional().describe(ORG_ID_DESC),
21
246
  }, async ({ organization_id }) => {
22
247
  try {
23
- const params = organization_id
24
- ? new URLSearchParams({ organizationId: organization_id })
25
- : undefined;
26
- const data = await getClient().get("/api/data-room", params);
248
+ const data = await fetchDataRoom(organization_id);
27
249
  return toolResult(data);
28
250
  }
29
251
  catch (error) {
30
252
  return handleToolError(error);
31
253
  }
32
254
  });
255
+ // ── describe_company_info_schema ───────────────────────────────────────
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 () => {
260
+ try {
261
+ return toolResult({
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",
301
+ },
302
+ products: {
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}}.",
304
+ patch_tool: "update_company_info_product",
305
+ },
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",
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).",
332
+ },
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.",
337
+ ],
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
+ },
342
+ notes: [
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.",
345
+ ],
346
+ });
347
+ }
348
+ catch (error) {
349
+ return handleToolError(error);
350
+ }
351
+ });
33
352
  // ── update_data_room ───────────────────────────────────────────────────
34
- server.tool("update_data_room", "Update the organization's company info in the data room. Accepts any freeform " +
35
- "fields under companyInfo common ones: name, sector, description, size, website, " +
36
- "products, valueProposition, competitors, targetRegions, sender. Only the fields " +
37
- "you pass are updated.", {
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.", {
38
361
  company_info: z
39
362
  .record(z.unknown())
40
- .describe("Freeform object of company details. Common fields: name, sector, description, " +
41
- "size, website, products, valueProposition, competitors, targetRegions, sender."),
363
+ .describe("Full v2 companyInfo payload (replaces the existing one). See describe_company_info_schema for the contract."),
42
364
  organization_id: z.string().optional().describe(ORG_ID_DESC),
43
- }, async ({ company_info, organization_id }) => {
365
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
366
+ }, async ({ company_info, organization_id, dry_run }) => {
44
367
  try {
45
- const body = { companyInfo: company_info };
46
- if (organization_id)
47
- body.organizationId = organization_id;
48
- const data = await getClient().put("/api/data-room", body);
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;
371
+ if (dry_run) {
372
+ return dryRunResult(company_info);
373
+ }
374
+ const data = await putCompanyInfo(company_info, organization_id);
49
375
  return toolResult(data);
50
376
  }
51
377
  catch (error) {
52
378
  return handleToolError(error);
53
379
  }
54
380
  });
381
+ // ── update_company_info_identity ───────────────────────────────────────
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)
393
+ .optional()
394
+ .describe("Company website URL (max 500)."),
395
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
396
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
397
+ }, async ({ name, website, organization_id, dry_run }) => {
398
+ try {
399
+ if (name === undefined && website === undefined) {
400
+ return toolError("Provide at least one of: name, website.");
401
+ }
402
+ return await mergeAndPut(organization_id, dry_run, (ci) => {
403
+ const out = { ...ci };
404
+ if (name !== undefined)
405
+ out.name = name;
406
+ if (website !== undefined)
407
+ out.website = website;
408
+ return out;
409
+ }, "Identity patch failed against v2 schema.");
410
+ }
411
+ catch (error) {
412
+ return handleToolError(error);
413
+ }
414
+ });
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."),
455
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
456
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
457
+ }, async ({ pitchOneLiner, marketSpecialty, stage, salesTeamSizeFR, geo, constraints, organization_id, dry_run, }) => {
458
+ try {
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.");
466
+ }
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.");
492
+ }
493
+ catch (error) {
494
+ return handleToolError(error);
495
+ }
496
+ });
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 id — entries 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()
509
+ .optional()
510
+ .describe("Zero-based index. Required for 'replace' and 'remove'."),
511
+ constraint: constraintSchema
512
+ .optional()
513
+ .describe("Constraint payload {label ≤150, type ∈ regulatory|brand|legal, note? ≤150}. Required for 'add' and 'replace'."),
514
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
515
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
516
+ }, async ({ action, index, constraint, organization_id, dry_run }) => {
517
+ try {
518
+ if ((action === "add" || action === "replace") && !constraint) {
519
+ return toolError(`constraint is required for action '${action}'.`);
520
+ }
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.");
556
+ }
557
+ catch (error) {
558
+ return handleToolError(error);
559
+ }
560
+ });
561
+ // ── update_company_info_product ────────────────────────────────────────
562
+ server.tool("update_company_info_product", "Add, replace, or remove a single product in companyInfo.products[] without re-sending " +
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.", {
569
+ action: z
570
+ .enum(["add", "replace", "remove"])
571
+ .describe("add = append (fails if name exists or list at cap 5); replace = overwrite by name; remove = delete by name."),
572
+ name: z
573
+ .string()
574
+ .optional()
575
+ .describe("Lookup key for replace/remove. Required for those actions. For 'add' the name comes from the product payload."),
576
+ product: productSchema
577
+ .optional()
578
+ .describe("Product payload. Required for 'add' and 'replace'."),
579
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
580
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
581
+ }, async ({ action, name, product, organization_id, dry_run }) => {
582
+ try {
583
+ if ((action === "add" || action === "replace") && !product) {
584
+ return toolError(`product is required for action '${action}'.`);
585
+ }
586
+ if ((action === "replace" || action === "remove") && !name) {
587
+ return toolError(`name is required for action '${action}'.`);
588
+ }
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);
602
+ }
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;
609
+ }
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);
618
+ }
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.");
669
+ }
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.");
687
+ }
688
+ catch (error) {
689
+ return handleToolError(error);
690
+ }
691
+ });
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]}}.", {
697
+ action: z
698
+ .enum(["add", "replace", "remove"])
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()
702
+ .optional()
703
+ .describe("Lookup key for replace/remove. For 'add' the productId comes from the icp payload."),
704
+ icp: productICPSchema
705
+ .optional()
706
+ .describe("ICP payload. Required for 'add' and 'replace'."),
707
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
708
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
709
+ }, async ({ action, productId, icp, organization_id, dry_run }) => {
710
+ try {
711
+ if ((action === "add" || action === "replace") && !icp) {
712
+ return toolError(`icp is required for action '${action}'.`);
713
+ }
714
+ if ((action === "replace" || action === "remove") && !productId) {
715
+ return toolError(`productId is required for action '${action}'.`);
716
+ }
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);
730
+ }
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);
746
+ }
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.");
780
+ }
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.");
796
+ }
797
+ catch (error) {
798
+ return handleToolError(error);
799
+ }
800
+ });
55
801
  // ── add_data_room_document ─────────────────────────────────────────────
56
802
  server.tool("add_data_room_document", "Add a textual/markdown document to the data room under a predefined category. " +
57
803
  "Use this to store product catalogs, congress lists, target lists, competitor briefs, " +
package/dist/types.js CHANGED
@@ -29,6 +29,17 @@ export function toolError(message, details) {
29
29
  }
30
30
  export function handleToolError(error) {
31
31
  if (error instanceof LeadifyApiError) {
32
+ // If the backend returned a cumulative ZodIssue list (formatZodError now
33
+ // emits `issues[]` on every 422), surface the count up-front in the
34
+ // message so the caller can fix every field in a single retry instead
35
+ // of looping one-error-at-a-time on the legacy `error` field.
36
+ const body = error.responseBody;
37
+ if (body && typeof body === "object" && !Array.isArray(body) && "issues" in body) {
38
+ const issues = body.issues;
39
+ if (Array.isArray(issues) && issues.length > 1) {
40
+ return toolError(`Validation failed: ${issues.length} issues. See details.response.issues[] for every path that needs fixing.`, { statusCode: error.statusCode, response: body });
41
+ }
42
+ }
32
43
  return toolError(error.message, {
33
44
  statusCode: error.statusCode,
34
45
  response: error.responseBody,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",