@agifyai/leadify-mcp 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -90,7 +198,14 @@ GitHub Actions build et publie sur npm automatiquement (workflow `.github/workfl
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
200
  | `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. |
201
+ | `describe_company_info_schema` | Lire le schéma companyInfo mirroré côté MCP (enums, max-lengths, sous-objets). À appeler avant la première update pour connaître le contrat. |
202
+ | `update_data_room` | Update full-form (escape hatch) — remplace les blocs JSON entièrement. Préférer les tools granulaires ci-dessous. |
203
+ | `update_company_info_identity` | Patch des champs scalaires (name, sector, description, size, website, valueProposition). |
204
+ | `update_company_info_sender` | Patch du sender (name, title, email) ou clear complet via set_to_null. |
205
+ | `update_company_info_geo` | Patch des listes targetRegions et competitors (replace / add / remove). |
206
+ | `update_company_info_snapshot` | Patch du bloc snapshot (stage, pitchOneLiner, constraints[]) avec validation enum + max-length. |
207
+ | `update_company_info_product` | Add / replace / remove un seul produit dans products[], lookup par name. category strict enum. |
208
+ | `update_company_info_constraint` | Add / replace / remove une seule contrainte dans snapshot.constraints[], lookup par index. type strict enum, note ≤150. |
94
209
  | `add_data_room_document` | Ajouter un document textuel à la data room avec catégorie. |
95
210
  | `upsert_persona` | Créer ou mettre à jour un persona (ciblage, messaging, outils actifs). |
96
211
  | `get_persona` | Récupérer un persona par son ID. |
@@ -107,11 +222,3 @@ GitHub Actions build et publie sur npm automatiquement (workflow `.github/workfl
107
222
  | `update_outreach_case_study` | Ajouter / remplacer / supprimer un case study par index, sans re-envoyer la liste. |
108
223
  | `update_outreach_urls` | Patch booking_url et/ou website_url uniquement. |
109
224
  | `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,97 @@
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
+ // ─── Mirrored backend constraints ──────────────────────────────────────────
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.
13
+ //
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
20
+ //
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.
25
+ const PRODUCT_CATEGORIES = ["saas", "medical_device", "service", "mixed"];
26
+ const STAGE_VALUES = ["early", "growth", "scaling", "enterprise"];
27
+ 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
72
+ .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
81
+ .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(),
93
+ })
94
+ .passthrough();
4
95
  const DOCUMENT_CATEGORIES = [
5
96
  "PRODUCT_CATALOG",
6
97
  "CONGRESS_LIST",
@@ -12,6 +103,52 @@ const DOCUMENT_CATEGORIES = [
12
103
  ];
13
104
  const ORG_ID_DESC = "Optional Clerk organization ID to target a specific org's data room. " +
14
105
  "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. " +
107
+ "Use to dry-fit a change before persisting.";
108
+ async function fetchDataRoom(organizationId) {
109
+ const params = organizationId
110
+ ? new URLSearchParams({ organizationId })
111
+ : undefined;
112
+ const data = (await getClient().get("/api/data-room", params));
113
+ return data ?? {};
114
+ }
115
+ async function putCompanyInfo(companyInfo, organizationId) {
116
+ const body = { companyInfo };
117
+ if (organizationId)
118
+ body.organizationId = organizationId;
119
+ return getClient().put("/api/data-room", body);
120
+ }
121
+ function currentCompanyInfo(state) {
122
+ const ci = state.companyInfo;
123
+ return ci && typeof ci === "object" && !Array.isArray(ci)
124
+ ? ci
125
+ : {};
126
+ }
127
+ function dryRunResult(patch, mergedCompanyInfo) {
128
+ return toolResult({
129
+ ok: true,
130
+ dry_run: true,
131
+ patch,
132
+ preview_company_info: mergedCompanyInfo,
133
+ note: "No write performed. Re-call without dry_run to persist.",
134
+ });
135
+ }
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 });
150
+ }
151
+ // ─── Tool registrations ────────────────────────────────────────────────────
15
152
  export function registerDataRoomTools(server) {
16
153
  // ── get_data_room ──────────────────────────────────────────────────────
17
154
  server.tool("get_data_room", "Retrieve the organization's full data room: company info, all documents, and every " +
@@ -20,32 +157,452 @@ export function registerDataRoomTools(server) {
20
157
  organization_id: z.string().optional().describe(ORG_ID_DESC),
21
158
  }, async ({ organization_id }) => {
22
159
  try {
23
- const params = organization_id
24
- ? new URLSearchParams({ organizationId: organization_id })
25
- : undefined;
26
- const data = await getClient().get("/api/data-room", params);
160
+ const data = await fetchDataRoom(organization_id);
27
161
  return toolResult(data);
28
162
  }
29
163
  catch (error) {
30
164
  return handleToolError(error);
31
165
  }
32
166
  });
167
+ // ── 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 () => {
175
+ try {
176
+ 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
+ },
194
+ },
195
+ 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
+ },
209
+ patch_tool: "update_company_info_product",
210
+ },
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",
234
+ },
235
+ },
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",
243
+ ],
244
+ escape_hatch: "update_data_room",
245
+ 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.",
249
+ ],
250
+ });
251
+ }
252
+ catch (error) {
253
+ return handleToolError(error);
254
+ }
255
+ });
33
256
  // ── 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.", {
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.", {
38
265
  company_info: z
39
266
  .record(z.unknown())
40
- .describe("Freeform object of company details. Common fields: name, sector, description, " +
41
- "size, website, products, valueProposition, competitors, targetRegions, sender."),
267
+ .describe("companyInfo payload. Known fields are validated against the mirrored schema " +
268
+ "(see describe_company_info_schema). Unknown fields pass through."),
42
269
  organization_id: z.string().optional().describe(ORG_ID_DESC),
43
- }, async ({ company_info, organization_id }) => {
270
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
271
+ }, async ({ company_info, organization_id, dry_run }) => {
44
272
  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);
273
+ const validationError = validateOrError(companyInfoSchema, company_info, "company_info");
274
+ if (validationError)
275
+ return validationError;
276
+ if (dry_run) {
277
+ const current = await fetchDataRoom(organization_id);
278
+ const merged = { ...currentCompanyInfo(current), ...company_info };
279
+ return dryRunResult(company_info, merged);
280
+ }
281
+ const data = await putCompanyInfo(company_info, organization_id);
282
+ return toolResult(data);
283
+ }
284
+ catch (error) {
285
+ return handleToolError(error);
286
+ }
287
+ });
288
+ // ── 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()
338
+ .optional()
339
+ .describe("If true, clears the sender block entirely (mutually exclusive with name/title/email)."),
340
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
341
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
342
+ }, async ({ name, title, email, set_to_null, organization_id, dry_run }) => {
343
+ 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;
359
+ }
360
+ else {
361
+ nextSender = { ...existingSender };
362
+ 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);
378
+ }
379
+ catch (error) {
380
+ return handleToolError(error);
381
+ }
382
+ });
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."),
397
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
398
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
399
+ }, async ({ field, action, values, organization_id, dry_run }) => {
400
+ 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])];
410
+ }
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);
421
+ }
422
+ catch (error) {
423
+ return handleToolError(error);
424
+ }
425
+ });
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)
442
+ .optional()
443
+ .describe("Full replacement of constraints[]. Use update_company_info_constraint for per-item edits."),
444
+ extra: z
445
+ .record(z.unknown())
446
+ .optional()
447
+ .describe("Escape hatch for additional snapshot keys not yet mirrored in the MCP."),
448
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
449
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
450
+ }, async ({ stage, pitchOneLiner, constraints, extra, organization_id, dry_run }) => {
451
+ 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.");
457
+ }
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);
478
+ }
479
+ catch (error) {
480
+ return handleToolError(error);
481
+ }
482
+ });
483
+ // ── update_company_info_product ────────────────────────────────────────
484
+ 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.", {
488
+ action: z
489
+ .enum(["add", "replace", "remove"])
490
+ .describe("add = append (fails if name already exists); replace = overwrite by name; remove = delete by name."),
491
+ name: z
492
+ .string()
493
+ .optional()
494
+ .describe("Lookup key for replace/remove. Required for those actions. For 'add' the name comes from the product payload."),
495
+ product: productSchema
496
+ .optional()
497
+ .describe("Product payload. Required for 'add' and 'replace'. Shape: {name, category, description?, valueProposition?, targetCustomer?, pricing?}."),
498
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
499
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
500
+ }, async ({ action, name, product, organization_id, dry_run }) => {
501
+ try {
502
+ if ((action === "add" || action === "replace") && !product) {
503
+ return toolError(`product is required for action '${action}'.`);
504
+ }
505
+ if ((action === "replace" || action === "remove") && !name) {
506
+ return toolError(`name is required for action '${action}'.`);
507
+ }
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.`);
517
+ }
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}".`);
524
+ }
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}".`);
532
+ }
533
+ list.length = 0;
534
+ list.push(...filtered);
535
+ }
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);
542
+ }
543
+ catch (error) {
544
+ return handleToolError(error);
545
+ }
546
+ });
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.", {
553
+ action: z
554
+ .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()
560
+ .optional()
561
+ .describe("Zero-based index. Required for 'replace' and 'remove'."),
562
+ constraint: constraintSchema
563
+ .optional()
564
+ .describe("Constraint payload {type ∈ regulatory|brand|legal, note ≤150 chars}. Required for 'add' and 'replace'."),
565
+ organization_id: z.string().optional().describe(ORG_ID_DESC),
566
+ dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
567
+ }, async ({ action, index, constraint, organization_id, dry_run }) => {
568
+ try {
569
+ if ((action === "add" || action === "replace") && !constraint) {
570
+ return toolError(`constraint is required for action '${action}'.`);
571
+ }
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);
587
+ }
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).`);
591
+ }
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).`);
597
+ }
598
+ list.splice(index, 1);
599
+ }
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);
49
606
  return toolResult(data);
50
607
  }
51
608
  catch (error) {
@@ -80,3 +637,20 @@ export function registerDataRoomTools(server) {
80
637
  }
81
638
  });
82
639
  }
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",