@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 +131 -24
- package/dist/tools/dataroom.js +590 -16
- package/package.json +1 -1
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
|
|
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
|
-
|
|
5
|
+
Package npm : [`@agifyai/leadify-mcp`](https://www.npmjs.com/package/@agifyai/leadify-mcp)
|
|
6
6
|
|
|
7
|
-
|
|
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 `
|
|
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
|
-
###
|
|
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
|
-
|
|
66
|
+
npm cache clean --force
|
|
37
67
|
```
|
|
38
68
|
|
|
39
|
-
###
|
|
69
|
+
### Dépannage
|
|
40
70
|
|
|
41
|
-
|
|
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
|
-
|
|
78
|
+
---
|
|
44
79
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
```
|
package/dist/tools/dataroom.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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("
|
|
41
|
-
"
|
|
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
|
-
|
|
270
|
+
dry_run: z.boolean().optional().describe(DRY_RUN_DESC),
|
|
271
|
+
}, async ({ company_info, organization_id, dry_run }) => {
|
|
44
272
|
try {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
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.
|