capsulemcp 1.0.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/LICENSE +201 -0
- package/README.md +85 -0
- package/dist/http.js +3305 -0
- package/dist/index.js +2593 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/capsule/client.ts
|
|
7
|
+
import { fetch } from "undici";
|
|
8
|
+
var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
|
|
9
|
+
function baseUrl() {
|
|
10
|
+
const override = process.env["CAPSULE_API_BASE_URL"];
|
|
11
|
+
if (!override) return DEFAULT_BASE_URL;
|
|
12
|
+
if (!URL.canParse(override)) {
|
|
13
|
+
throw new CapsuleAuthError(
|
|
14
|
+
`CAPSULE_API_BASE_URL is not a valid URL: ${JSON.stringify(override)}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const u = new URL(override);
|
|
18
|
+
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1";
|
|
19
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocal)) {
|
|
20
|
+
throw new CapsuleAuthError(
|
|
21
|
+
`CAPSULE_API_BASE_URL must be https:// (or http:// on localhost); got ${u.protocol}//${u.hostname}. Sending the Capsule API token to that URL would expose it.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return override;
|
|
25
|
+
}
|
|
26
|
+
function isReadOnly() {
|
|
27
|
+
const v = process.env["CAPSULE_MCP_READONLY"]?.toLowerCase();
|
|
28
|
+
return v === "1" || v === "true" || v === "yes";
|
|
29
|
+
}
|
|
30
|
+
var CapsuleReadOnlyError = class extends Error {
|
|
31
|
+
constructor(method) {
|
|
32
|
+
super(
|
|
33
|
+
`capsulemcp is running in read-only mode (CAPSULE_MCP_READONLY is set). ${method} requests are refused. Unset CAPSULE_MCP_READONLY to enable writes.`
|
|
34
|
+
);
|
|
35
|
+
this.name = "CapsuleReadOnlyError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var CapsuleAuthError = class extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "CapsuleAuthError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var CapsuleApiError = class extends Error {
|
|
45
|
+
constructor(status, message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.name = "CapsuleApiError";
|
|
49
|
+
}
|
|
50
|
+
status;
|
|
51
|
+
};
|
|
52
|
+
function getToken() {
|
|
53
|
+
const token = process.env["CAPSULE_API_TOKEN"];
|
|
54
|
+
if (!token) {
|
|
55
|
+
throw new CapsuleAuthError(
|
|
56
|
+
"CAPSULE_API_TOKEN environment variable is not set. Generate a Personal Access Token via My Preferences \u2192 API Authentication Tokens in Capsule."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
function baseHeaders(token) {
|
|
62
|
+
return {
|
|
63
|
+
Authorization: `Bearer ${token}`,
|
|
64
|
+
Accept: "application/json"
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function parseNextPage(linkHeader) {
|
|
68
|
+
if (!linkHeader) return void 0;
|
|
69
|
+
const match = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="next"/);
|
|
70
|
+
return match ? parseInt(match[1], 10) : void 0;
|
|
71
|
+
}
|
|
72
|
+
function parseRateLimitDelay(res) {
|
|
73
|
+
const DEFAULT_MS = 5e3;
|
|
74
|
+
const MAX_WAIT_MS = 6e4;
|
|
75
|
+
const resetRaw = res.headers.get("X-RateLimit-Reset");
|
|
76
|
+
if (resetRaw) {
|
|
77
|
+
const resetEpochSec = Number(resetRaw);
|
|
78
|
+
if (Number.isFinite(resetEpochSec) && resetEpochSec > 0) {
|
|
79
|
+
const delta = resetEpochSec * 1e3 - Date.now();
|
|
80
|
+
if (delta <= 0) return DEFAULT_MS;
|
|
81
|
+
return Math.min(delta, MAX_WAIT_MS);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
85
|
+
if (retryAfter) {
|
|
86
|
+
const seconds = Number(retryAfter);
|
|
87
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
88
|
+
return Math.min(seconds * 1e3, MAX_WAIT_MS);
|
|
89
|
+
}
|
|
90
|
+
const dateMs = Date.parse(retryAfter);
|
|
91
|
+
if (Number.isFinite(dateMs)) {
|
|
92
|
+
const delta = dateMs - Date.now();
|
|
93
|
+
return delta > 0 ? Math.min(delta, MAX_WAIT_MS) : DEFAULT_MS;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return DEFAULT_MS;
|
|
97
|
+
}
|
|
98
|
+
async function parseErrorBody(res) {
|
|
99
|
+
try {
|
|
100
|
+
const body = await res.json();
|
|
101
|
+
if (body.errors && body.errors.length > 0) {
|
|
102
|
+
return body.errors.map((e) => {
|
|
103
|
+
const parts = [e.resource, e.field].filter(Boolean).join(".");
|
|
104
|
+
return parts ? `${parts}: ${e.message ?? "invalid"}` : e.message ?? "invalid";
|
|
105
|
+
}).join("; ");
|
|
106
|
+
}
|
|
107
|
+
if (body.message) return body.message;
|
|
108
|
+
return res.statusText;
|
|
109
|
+
} catch {
|
|
110
|
+
return res.statusText;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
114
|
+
function withTimeout(options) {
|
|
115
|
+
if (options && options.signal !== void 0) {
|
|
116
|
+
return { options, cleanup: () => {
|
|
117
|
+
} };
|
|
118
|
+
}
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
121
|
+
timer.unref();
|
|
122
|
+
return {
|
|
123
|
+
options: { ...options ?? {}, signal: controller.signal },
|
|
124
|
+
cleanup: () => clearTimeout(timer)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function mapAbort(p) {
|
|
128
|
+
try {
|
|
129
|
+
return await p;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
|
132
|
+
throw new CapsuleApiError(
|
|
133
|
+
504,
|
|
134
|
+
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function fetchWithTimeout(url, options) {
|
|
141
|
+
const { options: opts, cleanup } = withTimeout(options);
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(url, opts);
|
|
144
|
+
return { res, cleanup };
|
|
145
|
+
} catch (err) {
|
|
146
|
+
cleanup();
|
|
147
|
+
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
|
148
|
+
throw new CapsuleApiError(
|
|
149
|
+
504,
|
|
150
|
+
`Capsule API request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s. The Capsule API may be slow or hung; retry after a short wait. If the failed call was a write/delete, read the entity first to see whether the change actually applied before retrying.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function doFetch(url, options) {
|
|
157
|
+
const first = await fetchWithTimeout(url, options);
|
|
158
|
+
if (first.res.status === 429) {
|
|
159
|
+
const delay = parseRateLimitDelay(first.res);
|
|
160
|
+
first.cleanup();
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
162
|
+
const retried = await fetchWithTimeout(url, options);
|
|
163
|
+
if (retried.res.status === 429) {
|
|
164
|
+
retried.cleanup();
|
|
165
|
+
throw new CapsuleApiError(
|
|
166
|
+
429,
|
|
167
|
+
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return retried;
|
|
171
|
+
}
|
|
172
|
+
return first;
|
|
173
|
+
}
|
|
174
|
+
async function throwForStatus(res) {
|
|
175
|
+
if (res.status === 401) {
|
|
176
|
+
const detail = await parseErrorBody(res);
|
|
177
|
+
throw new CapsuleAuthError(
|
|
178
|
+
`Capsule API returned 401 Unauthorized: ${detail}. Check that CAPSULE_API_TOKEN is valid and not expired.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const msg = await parseErrorBody(res);
|
|
183
|
+
throw new CapsuleApiError(res.status, `Capsule API error ${res.status}: ${msg}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function handleResponse(res) {
|
|
187
|
+
await throwForStatus(res);
|
|
188
|
+
return mapAbort(res.json());
|
|
189
|
+
}
|
|
190
|
+
function buildUrl(path, params) {
|
|
191
|
+
const url = new URL(`${baseUrl()}${path}`);
|
|
192
|
+
if (params) {
|
|
193
|
+
for (const [key, value] of Object.entries(params)) {
|
|
194
|
+
if (value !== void 0) {
|
|
195
|
+
url.searchParams.set(key, String(value));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return url.toString();
|
|
200
|
+
}
|
|
201
|
+
async function capsuleGet(path, params) {
|
|
202
|
+
const token = getToken();
|
|
203
|
+
const url = buildUrl(path, params);
|
|
204
|
+
const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
|
|
205
|
+
try {
|
|
206
|
+
const data = await handleResponse(res);
|
|
207
|
+
const nextPage = parseNextPage(res.headers.get("Link"));
|
|
208
|
+
return { data, nextPage };
|
|
209
|
+
} finally {
|
|
210
|
+
cleanup();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function capsulePost(path, body) {
|
|
214
|
+
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
215
|
+
const token = getToken();
|
|
216
|
+
const url = buildUrl(path);
|
|
217
|
+
const { res, cleanup } = await doFetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
220
|
+
body: JSON.stringify(body)
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
return await handleResponse(res);
|
|
224
|
+
} finally {
|
|
225
|
+
cleanup();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function capsulePostNoContent(path) {
|
|
229
|
+
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
230
|
+
const token = getToken();
|
|
231
|
+
const url = buildUrl(path);
|
|
232
|
+
const { res, cleanup } = await doFetch(url, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: baseHeaders(token)
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
if (res.status === 204) return;
|
|
238
|
+
await throwForStatus(res);
|
|
239
|
+
await mapAbort(res.text());
|
|
240
|
+
} finally {
|
|
241
|
+
cleanup();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function capsuleSearch(path, body, params) {
|
|
245
|
+
const token = getToken();
|
|
246
|
+
const url = buildUrl(path, params);
|
|
247
|
+
const { res, cleanup } = await doFetch(url, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
250
|
+
body: JSON.stringify(body)
|
|
251
|
+
});
|
|
252
|
+
try {
|
|
253
|
+
const data = await handleResponse(res);
|
|
254
|
+
const nextPage = parseNextPage(res.headers.get("Link"));
|
|
255
|
+
return { data, nextPage };
|
|
256
|
+
} finally {
|
|
257
|
+
cleanup();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function capsulePut(path, body) {
|
|
261
|
+
if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
|
|
262
|
+
const token = getToken();
|
|
263
|
+
const url = buildUrl(path);
|
|
264
|
+
const { res, cleanup } = await doFetch(url, {
|
|
265
|
+
method: "PUT",
|
|
266
|
+
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
267
|
+
body: JSON.stringify(body)
|
|
268
|
+
});
|
|
269
|
+
try {
|
|
270
|
+
return await handleResponse(res);
|
|
271
|
+
} finally {
|
|
272
|
+
cleanup();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function capsuleGetBinary(path, maxBytes) {
|
|
276
|
+
const token = getToken();
|
|
277
|
+
const url = buildUrl(path);
|
|
278
|
+
const { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
|
|
279
|
+
try {
|
|
280
|
+
await throwForStatus(res);
|
|
281
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
282
|
+
const declared = res.headers.get("Content-Length");
|
|
283
|
+
const declaredBytes = declared ? Number(declared) : NaN;
|
|
284
|
+
if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
285
|
+
if (res.body) await res.body.cancel().catch(() => {
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
contentType,
|
|
289
|
+
buffer: Buffer.alloc(0),
|
|
290
|
+
truncated: true,
|
|
291
|
+
sizeBytes: declaredBytes
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (maxBytes !== void 0 && res.body) {
|
|
295
|
+
const reader = res.body.getReader();
|
|
296
|
+
const chunks = [];
|
|
297
|
+
let total = 0;
|
|
298
|
+
let truncated = false;
|
|
299
|
+
while (true) {
|
|
300
|
+
const { done, value } = await mapAbort(reader.read());
|
|
301
|
+
if (done) break;
|
|
302
|
+
total += value.byteLength;
|
|
303
|
+
if (total > maxBytes) {
|
|
304
|
+
truncated = true;
|
|
305
|
+
await reader.cancel().catch(() => {
|
|
306
|
+
});
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
chunks.push(value);
|
|
310
|
+
}
|
|
311
|
+
if (truncated) {
|
|
312
|
+
return {
|
|
313
|
+
contentType,
|
|
314
|
+
buffer: Buffer.alloc(0),
|
|
315
|
+
truncated: true,
|
|
316
|
+
sizeBytes: total
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
320
|
+
return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
|
|
321
|
+
}
|
|
322
|
+
const arrayBuffer = await mapAbort(res.arrayBuffer());
|
|
323
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
324
|
+
return { contentType, buffer, sizeBytes: buffer.length };
|
|
325
|
+
} finally {
|
|
326
|
+
cleanup();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function capsulePostBinary(path, body, contentType, filename) {
|
|
330
|
+
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
331
|
+
const token = getToken();
|
|
332
|
+
const url = buildUrl(path);
|
|
333
|
+
const { res, cleanup } = await doFetch(url, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
...baseHeaders(token),
|
|
337
|
+
"Content-Type": contentType,
|
|
338
|
+
"Content-Length": String(body.length),
|
|
339
|
+
"X-Attachment-Filename": encodeURIComponent(filename)
|
|
340
|
+
},
|
|
341
|
+
body
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
return await handleResponse(res);
|
|
345
|
+
} finally {
|
|
346
|
+
cleanup();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function capsuleDelete(path) {
|
|
350
|
+
if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
|
|
351
|
+
const token = getToken();
|
|
352
|
+
const url = buildUrl(path);
|
|
353
|
+
const { res, cleanup } = await doFetch(url, {
|
|
354
|
+
method: "DELETE",
|
|
355
|
+
headers: baseHeaders(token)
|
|
356
|
+
});
|
|
357
|
+
try {
|
|
358
|
+
if (res.status === 204) return;
|
|
359
|
+
await throwForStatus(res);
|
|
360
|
+
await mapAbort(res.text());
|
|
361
|
+
} finally {
|
|
362
|
+
cleanup();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/server.ts
|
|
367
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
368
|
+
|
|
369
|
+
// src/icon.ts
|
|
370
|
+
var ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="capsulemcp">
|
|
371
|
+
<!-- Stylised diagonal capsule: two halves with a faint highlight stripe.
|
|
372
|
+
Designed for crisp display down to 16x16. Visually neutral \u2014 does
|
|
373
|
+
not reproduce any Capsule CRM trademark. -->
|
|
374
|
+
<defs>
|
|
375
|
+
<clipPath id="cap">
|
|
376
|
+
<rect x="2" y="22" width="60" height="20" rx="10" ry="10"
|
|
377
|
+
transform="rotate(-32 32 32)"/>
|
|
378
|
+
</clipPath>
|
|
379
|
+
</defs>
|
|
380
|
+
<g clip-path="url(#cap)">
|
|
381
|
+
<rect x="0" y="0" width="32" height="64" fill="#3B82F6"/>
|
|
382
|
+
<rect x="32" y="0" width="32" height="64" fill="#1E3A8A"/>
|
|
383
|
+
<rect x="2" y="22" width="60" height="2" fill="rgba(255,255,255,0.35)"
|
|
384
|
+
transform="rotate(-32 32 32)"/>
|
|
385
|
+
</g>
|
|
386
|
+
<rect x="2" y="22" width="60" height="20" rx="10" ry="10"
|
|
387
|
+
transform="rotate(-32 32 32)" fill="none"
|
|
388
|
+
stroke="rgba(0,0,0,0.15)" stroke-width="1"/>
|
|
389
|
+
</svg>`;
|
|
390
|
+
var ICON_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(ICON_SVG, "utf8").toString("base64")}`;
|
|
391
|
+
var ICONS = [
|
|
392
|
+
{
|
|
393
|
+
src: ICON_DATA_URI,
|
|
394
|
+
mimeType: "image/svg+xml",
|
|
395
|
+
sizes: ["64x64", "any"]
|
|
396
|
+
}
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
// src/server/register-tool.ts
|
|
400
|
+
function wrapAsText(result) {
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function registerTool(server2, name, description, schema, handler) {
|
|
406
|
+
const registerWithSchema = server2.registerTool.bind(server2);
|
|
407
|
+
registerWithSchema(name, { description, inputSchema: schema }, async (input) => {
|
|
408
|
+
const result = await handler(input);
|
|
409
|
+
return wrapAsText(result);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/tools/parties.ts
|
|
414
|
+
import { z as z3 } from "zod";
|
|
415
|
+
|
|
416
|
+
// src/tools/descriptions.ts
|
|
417
|
+
var EMBED_TAGS_FIELDS_DESCRIPTION = "Comma-separated embeds, e.g. 'tags,fields'";
|
|
418
|
+
var EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION = "Comma-separated embeds, e.g. 'attachments,participants'";
|
|
419
|
+
|
|
420
|
+
// src/tools/confirm-flag.ts
|
|
421
|
+
import { z } from "zod";
|
|
422
|
+
var CONFIRM_REQUIRED_MESSAGE = "confirm: true is required to perform this destructive operation (set the parameter explicitly to acknowledge the destructive intent)";
|
|
423
|
+
function confirmFlag() {
|
|
424
|
+
return z.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/capsule/idempotent.ts
|
|
428
|
+
var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
|
|
429
|
+
var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
|
|
430
|
+
async function idempotent(op, success, alreadyDone, isAlreadyDoneError = isCapsule404) {
|
|
431
|
+
try {
|
|
432
|
+
await op();
|
|
433
|
+
return success();
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (isAlreadyDoneError(err)) return alreadyDone();
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function idempotentWithResult(op, success, alreadyDone, isAlreadyDoneError = isCapsule404) {
|
|
440
|
+
try {
|
|
441
|
+
const result = await op();
|
|
442
|
+
return success(result);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (isAlreadyDoneError(err)) return alreadyDone();
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/tools/custom-field-helpers.ts
|
|
450
|
+
import { z as z2 } from "zod";
|
|
451
|
+
var CustomFieldWriteSchema = z2.object({
|
|
452
|
+
definitionId: z2.number().int().positive().describe(
|
|
453
|
+
"The custom-field definition id from list_custom_fields. Identifies which field on the entity to set."
|
|
454
|
+
),
|
|
455
|
+
value: z2.union([z2.string(), z2.number(), z2.boolean(), z2.null()]).describe(
|
|
456
|
+
"The new value. String for TEXT / DATE / LIST / LARGE_TEXT / LINK fields, number for NUMBER fields, boolean for BOOLEAN fields. Clearing: pass null for TEXT / NUMBER / DATE / LIST (Capsule removes the row). BOOLEAN does NOT accept null (Capsule returns 422 'invalid type for field'); use `value: false` instead. Note BOOLEAN fields are observably **two-state**: a row exists with `value: true`, or no row exists. Setting `value: false` removes the row entirely \u2014 readers should treat absent BOOLEAN rows as equivalent to false. Tri-state BOOLEAN semantics (true / false / unknown) are not achievable through Capsule's API. Audit-log noise: sending value=null on a field that's already empty/cleared is accepted by Capsule but still bumps the parent entity's `updatedAt`. Read the current value via embed='fields' first if `updatedAt` is being used as a 'last meaningful change' signal. NUMBER quirks: Capsule stores numerics correctly but the read-back via embed=fields returns them as STRINGS (e.g. value=3 reads as '3'); callers comparing values must coerce. TEXT quirks: value='' has the same observable effect as value=null (row removed); empty-string and never-set are indistinguishable."
|
|
457
|
+
)
|
|
458
|
+
});
|
|
459
|
+
function fieldsArrayDescriptor(entityToolName) {
|
|
460
|
+
return `Set custom field values on this record. PARTIAL UPDATE: only the definitions you list are touched; any field NOT in this array is left unchanged. Discover available definitions via list_custom_fields; read current values via ${entityToolName} with embed='fields'.`;
|
|
461
|
+
}
|
|
462
|
+
function mapFieldsForBody(fields) {
|
|
463
|
+
if (fields === void 0) return void 0;
|
|
464
|
+
return fields.map((f) => ({
|
|
465
|
+
definition: { id: f.definitionId },
|
|
466
|
+
value: f.value
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/tools/parties.ts
|
|
471
|
+
var EmailAddressSchema = z3.object({
|
|
472
|
+
address: z3.string().email(),
|
|
473
|
+
type: z3.string().optional()
|
|
474
|
+
});
|
|
475
|
+
var PhoneNumberSchema = z3.object({
|
|
476
|
+
// Capsule rejects empty strings with `phoneNumber.number: number is
|
|
477
|
+
// required`. Enforce at the schema layer to catch typos pre-call,
|
|
478
|
+
// matching how EmailAddressSchema's address field behaves.
|
|
479
|
+
number: z3.string().min(1),
|
|
480
|
+
type: z3.string().optional()
|
|
481
|
+
});
|
|
482
|
+
var CountryDescription = "Country name. Capsule validates this against a small canonical-English-name dictionary; inputs not in the dictionary are REJECTED with 422 'address.country: unknown country' (NOT silently passed through or normalised). Probed examples \u2014 accepted: `United States`, `United Kingdom`, `Czechia`, `Germany`. Aliased: `USA \u2192 United States`. Rejected: `United States of America`, `Czech Republic` (use `Czechia`), `UK`/`Britain` (use `United Kingdom`), `Deutschland` (use `Germany`). Empty string is accepted and stored as `null` \u2014 a de-facto 'clear' shape. To discover an accepted name, read an existing party that already has the country set.";
|
|
483
|
+
var AddressSchema = z3.object({
|
|
484
|
+
street: z3.string().optional(),
|
|
485
|
+
city: z3.string().optional(),
|
|
486
|
+
state: z3.string().optional(),
|
|
487
|
+
country: z3.string().optional().describe(CountryDescription),
|
|
488
|
+
zip: z3.string().optional()
|
|
489
|
+
});
|
|
490
|
+
function validateWebsiteAddress(data, ctx) {
|
|
491
|
+
const isUrlService = data.service === void 0 || data.service === "URL";
|
|
492
|
+
if (!isUrlService) return;
|
|
493
|
+
if (!URL.canParse(data.address)) {
|
|
494
|
+
ctx.addIssue({
|
|
495
|
+
code: "custom",
|
|
496
|
+
path: ["address"],
|
|
497
|
+
message: "When service is 'URL' (or omitted), address must be a valid URL like 'https://example.com'. For a social handle, set service to the matching type (e.g. service='TWITTER', address='@handle')."
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const parsed = new URL(data.address);
|
|
502
|
+
const BLOCKED = /* @__PURE__ */ new Set(["javascript:", "data:", "vbscript:"]);
|
|
503
|
+
if (BLOCKED.has(parsed.protocol)) {
|
|
504
|
+
ctx.addIssue({
|
|
505
|
+
code: "custom",
|
|
506
|
+
path: ["address"],
|
|
507
|
+
message: `When service is 'URL', address protocol '${parsed.protocol}' is not allowed. Use http: or https:.`
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
var WebsiteServiceEnum = z3.enum([
|
|
512
|
+
"URL",
|
|
513
|
+
"SKYPE",
|
|
514
|
+
"TWITTER",
|
|
515
|
+
"LINKED_IN",
|
|
516
|
+
"FACEBOOK",
|
|
517
|
+
"XING",
|
|
518
|
+
"FEED",
|
|
519
|
+
"GOOGLE_PLUS",
|
|
520
|
+
"FLICKR",
|
|
521
|
+
"GITHUB",
|
|
522
|
+
"YOUTUBE",
|
|
523
|
+
"INSTAGRAM",
|
|
524
|
+
"PINTEREST",
|
|
525
|
+
"TIKTOK",
|
|
526
|
+
"THREADS",
|
|
527
|
+
"BLUESKY",
|
|
528
|
+
"SNAPCHAT"
|
|
529
|
+
]);
|
|
530
|
+
var WebsiteSchema = z3.object({
|
|
531
|
+
address: z3.string().min(1).describe(
|
|
532
|
+
"The website address. A URL when service='URL', or a handle (e.g. '@acmeco') for social services like 'TWITTER', 'INSTAGRAM'. Capsule names this field `address` regardless of service type."
|
|
533
|
+
),
|
|
534
|
+
service: WebsiteServiceEnum.optional().describe(
|
|
535
|
+
"Service type. One of: URL, SKYPE, TWITTER, LINKED_IN, FACEBOOK, XING, FEED, GOOGLE_PLUS, FLICKR, GITHUB, YOUTUBE, INSTAGRAM, PINTEREST, TIKTOK, THREADS, BLUESKY, SNAPCHAT. Defaults to 'URL' if omitted."
|
|
536
|
+
)
|
|
537
|
+
}).superRefine(validateWebsiteAddress);
|
|
538
|
+
var searchPartiesSchema = z3.object({
|
|
539
|
+
q: z3.string().optional().describe("Free-text search query"),
|
|
540
|
+
embed: z3.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
541
|
+
page: z3.number().int().positive().optional().default(1),
|
|
542
|
+
perPage: z3.number().int().min(1).max(100).optional().default(25)
|
|
543
|
+
});
|
|
544
|
+
async function searchParties(input) {
|
|
545
|
+
const path = input.q ? "/parties/search" : "/parties";
|
|
546
|
+
const { data, nextPage } = await capsuleGet(path, {
|
|
547
|
+
q: input.q,
|
|
548
|
+
embed: input.embed,
|
|
549
|
+
page: input.page,
|
|
550
|
+
perPage: input.perPage
|
|
551
|
+
});
|
|
552
|
+
return { ...data, nextPage };
|
|
553
|
+
}
|
|
554
|
+
var getPartySchema = z3.object({
|
|
555
|
+
id: z3.number().int().positive().describe("Party ID"),
|
|
556
|
+
embed: z3.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
557
|
+
});
|
|
558
|
+
async function getParty(input) {
|
|
559
|
+
const { data } = await capsuleGet(`/parties/${input.id}`, {
|
|
560
|
+
embed: input.embed
|
|
561
|
+
});
|
|
562
|
+
return data;
|
|
563
|
+
}
|
|
564
|
+
var getPartiesSchema = z3.object({
|
|
565
|
+
ids: z3.array(z3.number().int().positive()).min(1).max(10).describe("Array of party IDs (1\u201310). Capsule caps batch fetches at 10."),
|
|
566
|
+
embed: z3.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
567
|
+
});
|
|
568
|
+
async function getParties(input) {
|
|
569
|
+
const { data } = await capsuleGet(`/parties/${input.ids.join(",")}`, {
|
|
570
|
+
embed: input.embed
|
|
571
|
+
});
|
|
572
|
+
return data;
|
|
573
|
+
}
|
|
574
|
+
var listPartyOpportunitiesSchema = z3.object({
|
|
575
|
+
partyId: z3.number().int().positive(),
|
|
576
|
+
page: z3.number().int().positive().optional().default(1),
|
|
577
|
+
perPage: z3.number().int().min(1).max(100).optional().default(25)
|
|
578
|
+
});
|
|
579
|
+
async function listPartyOpportunities(input) {
|
|
580
|
+
const { data, nextPage } = await capsuleGet(
|
|
581
|
+
`/parties/${input.partyId}/opportunities`,
|
|
582
|
+
{ page: input.page, perPage: input.perPage }
|
|
583
|
+
);
|
|
584
|
+
return { ...data, nextPage };
|
|
585
|
+
}
|
|
586
|
+
var listPartyProjectsSchema = z3.object({
|
|
587
|
+
partyId: z3.number().int().positive(),
|
|
588
|
+
page: z3.number().int().positive().optional().default(1),
|
|
589
|
+
perPage: z3.number().int().min(1).max(100).optional().default(25)
|
|
590
|
+
});
|
|
591
|
+
async function listPartyProjects(input) {
|
|
592
|
+
const { data, nextPage } = await capsuleGet(
|
|
593
|
+
`/parties/${input.partyId}/kases`,
|
|
594
|
+
{ page: input.page, perPage: input.perPage }
|
|
595
|
+
);
|
|
596
|
+
return { ...data, nextPage };
|
|
597
|
+
}
|
|
598
|
+
var PartyWriteBaseSchema = {
|
|
599
|
+
about: z3.string().optional(),
|
|
600
|
+
emailAddresses: z3.array(EmailAddressSchema).optional().describe(
|
|
601
|
+
"APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_email_address and remove_party_email_address_by_id. Passing `[]` here is a silent no-op (does not clear the list and does not advance updatedAt)."
|
|
602
|
+
),
|
|
603
|
+
phoneNumbers: z3.array(PhoneNumberSchema).optional().describe(
|
|
604
|
+
"APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_phone_number and remove_party_phone_number_by_id."
|
|
605
|
+
),
|
|
606
|
+
addresses: z3.array(AddressSchema).optional().describe(
|
|
607
|
+
"APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_address and remove_party_address_by_id. The `country` field is mapped through Capsule's country dictionary \u2014 see `add_party_address.country` for the dictionary edges (small canonical-English-name list; inputs not in the dictionary are REJECTED with 422, not silently dropped)."
|
|
608
|
+
),
|
|
609
|
+
websites: z3.array(WebsiteSchema).optional().describe(
|
|
610
|
+
"APPEND-ONLY: items are merged into the existing list, never replaced. For atomic add/remove/replace use add_party_website and remove_party_website_by_id."
|
|
611
|
+
),
|
|
612
|
+
ownerId: z3.number().int().positive().optional().describe(
|
|
613
|
+
"Assign to user ID. On create_party, defaults to the API-token owner when omitted. Once set, this connector cannot clear the owner back to null \u2014 use Capsule's web UI for that. Discover IDs via list_users."
|
|
614
|
+
)
|
|
615
|
+
};
|
|
616
|
+
var createPartySchema = z3.object({
|
|
617
|
+
type: z3.enum(["person", "organisation"]),
|
|
618
|
+
// person
|
|
619
|
+
firstName: z3.string().optional(),
|
|
620
|
+
lastName: z3.string().optional(),
|
|
621
|
+
title: z3.string().optional(),
|
|
622
|
+
jobTitle: z3.string().optional(),
|
|
623
|
+
organisationId: z3.number().int().positive().optional().describe("Link person to an existing organisation ID"),
|
|
624
|
+
// organisation
|
|
625
|
+
name: z3.string().optional(),
|
|
626
|
+
...PartyWriteBaseSchema
|
|
627
|
+
});
|
|
628
|
+
async function createParty(input) {
|
|
629
|
+
const { ownerId, organisationId, ...rest } = input;
|
|
630
|
+
const body = { ...rest };
|
|
631
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
632
|
+
if (organisationId) body["organisation"] = { id: organisationId };
|
|
633
|
+
return capsulePost("/parties", { party: body });
|
|
634
|
+
}
|
|
635
|
+
var updatePartySchema = z3.object({
|
|
636
|
+
id: z3.number().int().positive(),
|
|
637
|
+
firstName: z3.string().optional(),
|
|
638
|
+
lastName: z3.string().optional(),
|
|
639
|
+
title: z3.string().optional(),
|
|
640
|
+
jobTitle: z3.string().optional(),
|
|
641
|
+
name: z3.string().optional(),
|
|
642
|
+
fields: z3.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_party")),
|
|
643
|
+
...PartyWriteBaseSchema
|
|
644
|
+
});
|
|
645
|
+
async function updateParty(input) {
|
|
646
|
+
const { id, ownerId, fields, ...rest } = input;
|
|
647
|
+
const body = {};
|
|
648
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
649
|
+
if (v !== void 0) body[k] = v;
|
|
650
|
+
}
|
|
651
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
652
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
653
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
654
|
+
return capsulePut(`/parties/${id}`, { party: body });
|
|
655
|
+
}
|
|
656
|
+
var deletePartySchema = z3.object({
|
|
657
|
+
id: z3.number().int().positive(),
|
|
658
|
+
confirm: confirmFlag().describe(
|
|
659
|
+
"Must be set to true. Deletes the party AND all linked notes, tasks, opportunities, and projects (kases). Deleting an ORGANISATION does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. Irreversible."
|
|
660
|
+
)
|
|
661
|
+
});
|
|
662
|
+
async function deleteParty(input) {
|
|
663
|
+
if (input.confirm !== true) {
|
|
664
|
+
throw new Error("delete_party requires confirm: true");
|
|
665
|
+
}
|
|
666
|
+
return idempotent(
|
|
667
|
+
() => capsuleDelete(`/parties/${input.id}`),
|
|
668
|
+
() => ({ deleted: true, alreadyDeleted: false, id: input.id }),
|
|
669
|
+
() => ({ deleted: true, alreadyDeleted: true, id: input.id })
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
var addPartyEmailAddressSchema = z3.object({
|
|
673
|
+
partyId: z3.number().int().positive(),
|
|
674
|
+
address: z3.string().email(),
|
|
675
|
+
type: z3.string().optional().describe("Free-form label, e.g. 'Work', 'Home'.")
|
|
676
|
+
});
|
|
677
|
+
async function addPartyEmailAddress(input) {
|
|
678
|
+
const { partyId, address, type } = input;
|
|
679
|
+
const item = { address };
|
|
680
|
+
if (type !== void 0) item["type"] = type;
|
|
681
|
+
return capsulePut(`/parties/${partyId}`, {
|
|
682
|
+
party: { emailAddresses: [item] }
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
var removePartyEmailAddressByIdSchema = z3.object({
|
|
686
|
+
partyId: z3.number().int().positive(),
|
|
687
|
+
emailAddressId: z3.number().int().positive().describe(
|
|
688
|
+
"Capsule's id for the email-address row. Read it from get_party (each entry in emailAddresses carries an id)."
|
|
689
|
+
)
|
|
690
|
+
});
|
|
691
|
+
async function removePartyEmailAddressById(input) {
|
|
692
|
+
const { partyId, emailAddressId } = input;
|
|
693
|
+
return idempotentWithResult(
|
|
694
|
+
() => capsulePut(`/parties/${partyId}`, {
|
|
695
|
+
party: { emailAddresses: [{ id: emailAddressId, _delete: true }] }
|
|
696
|
+
}),
|
|
697
|
+
(result) => ({
|
|
698
|
+
removed: true,
|
|
699
|
+
alreadyRemoved: false,
|
|
700
|
+
partyId,
|
|
701
|
+
emailAddressId,
|
|
702
|
+
...result
|
|
703
|
+
}),
|
|
704
|
+
() => ({ removed: true, alreadyRemoved: true, partyId, emailAddressId })
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
var addPartyPhoneNumberSchema = z3.object({
|
|
708
|
+
partyId: z3.number().int().positive(),
|
|
709
|
+
number: z3.string().min(1),
|
|
710
|
+
type: z3.string().optional().describe("Free-form label, e.g. 'Work', 'Mobile'.")
|
|
711
|
+
});
|
|
712
|
+
async function addPartyPhoneNumber(input) {
|
|
713
|
+
const { partyId, number, type } = input;
|
|
714
|
+
const item = { number };
|
|
715
|
+
if (type !== void 0) item["type"] = type;
|
|
716
|
+
return capsulePut(`/parties/${partyId}`, {
|
|
717
|
+
party: { phoneNumbers: [item] }
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
var removePartyPhoneNumberByIdSchema = z3.object({
|
|
721
|
+
partyId: z3.number().int().positive(),
|
|
722
|
+
phoneNumberId: z3.number().int().positive().describe(
|
|
723
|
+
"Capsule's id for the phone-number row. Read it from get_party (each entry in phoneNumbers carries an id)."
|
|
724
|
+
)
|
|
725
|
+
});
|
|
726
|
+
async function removePartyPhoneNumberById(input) {
|
|
727
|
+
const { partyId, phoneNumberId } = input;
|
|
728
|
+
return idempotentWithResult(
|
|
729
|
+
() => capsulePut(`/parties/${partyId}`, {
|
|
730
|
+
party: { phoneNumbers: [{ id: phoneNumberId, _delete: true }] }
|
|
731
|
+
}),
|
|
732
|
+
(result) => ({
|
|
733
|
+
removed: true,
|
|
734
|
+
alreadyRemoved: false,
|
|
735
|
+
partyId,
|
|
736
|
+
phoneNumberId,
|
|
737
|
+
...result
|
|
738
|
+
}),
|
|
739
|
+
() => ({ removed: true, alreadyRemoved: true, partyId, phoneNumberId })
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
var addPartyAddressSchema = z3.object({
|
|
743
|
+
partyId: z3.number().int().positive(),
|
|
744
|
+
street: z3.string().optional(),
|
|
745
|
+
city: z3.string().optional(),
|
|
746
|
+
state: z3.string().optional(),
|
|
747
|
+
country: z3.string().optional().describe(CountryDescription),
|
|
748
|
+
zip: z3.string().optional(),
|
|
749
|
+
type: z3.string().optional().describe("Free-form label, e.g. 'Office', 'Home'.")
|
|
750
|
+
});
|
|
751
|
+
async function addPartyAddress(input) {
|
|
752
|
+
const { partyId, ...rest } = input;
|
|
753
|
+
const item = {};
|
|
754
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
755
|
+
if (v !== void 0) item[k] = v;
|
|
756
|
+
}
|
|
757
|
+
return capsulePut(`/parties/${partyId}`, {
|
|
758
|
+
party: { addresses: [item] }
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
var removePartyAddressByIdSchema = z3.object({
|
|
762
|
+
partyId: z3.number().int().positive(),
|
|
763
|
+
addressId: z3.number().int().positive().describe(
|
|
764
|
+
"Capsule's id for the address row. Read it from get_party (each entry in addresses carries an id)."
|
|
765
|
+
)
|
|
766
|
+
});
|
|
767
|
+
async function removePartyAddressById(input) {
|
|
768
|
+
const { partyId, addressId } = input;
|
|
769
|
+
return idempotentWithResult(
|
|
770
|
+
() => capsulePut(`/parties/${partyId}`, {
|
|
771
|
+
party: { addresses: [{ id: addressId, _delete: true }] }
|
|
772
|
+
}),
|
|
773
|
+
(result) => ({
|
|
774
|
+
removed: true,
|
|
775
|
+
alreadyRemoved: false,
|
|
776
|
+
partyId,
|
|
777
|
+
addressId,
|
|
778
|
+
...result
|
|
779
|
+
}),
|
|
780
|
+
() => ({ removed: true, alreadyRemoved: true, partyId, addressId })
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
var addPartyWebsiteSchema = z3.object({
|
|
784
|
+
partyId: z3.number().int().positive(),
|
|
785
|
+
address: z3.string().min(1).describe(
|
|
786
|
+
"The website address. A URL when service='URL', or a handle (e.g. '@acmeco') for social services."
|
|
787
|
+
),
|
|
788
|
+
service: WebsiteServiceEnum.optional().describe("Defaults to 'URL' if omitted.")
|
|
789
|
+
}).superRefine(validateWebsiteAddress);
|
|
790
|
+
async function addPartyWebsite(input) {
|
|
791
|
+
const { partyId, address, service } = input;
|
|
792
|
+
const item = { address };
|
|
793
|
+
if (service !== void 0) item["service"] = service;
|
|
794
|
+
return capsulePut(`/parties/${partyId}`, {
|
|
795
|
+
party: { websites: [item] }
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
var removePartyWebsiteByIdSchema = z3.object({
|
|
799
|
+
partyId: z3.number().int().positive(),
|
|
800
|
+
websiteId: z3.number().int().positive().describe(
|
|
801
|
+
"Capsule's id for the website row. Read it from get_party (each entry in websites carries an id)."
|
|
802
|
+
)
|
|
803
|
+
});
|
|
804
|
+
async function removePartyWebsiteById(input) {
|
|
805
|
+
const { partyId, websiteId } = input;
|
|
806
|
+
return idempotentWithResult(
|
|
807
|
+
() => capsulePut(`/parties/${partyId}`, {
|
|
808
|
+
party: { websites: [{ id: websiteId, _delete: true }] }
|
|
809
|
+
}),
|
|
810
|
+
(result) => ({
|
|
811
|
+
removed: true,
|
|
812
|
+
alreadyRemoved: false,
|
|
813
|
+
partyId,
|
|
814
|
+
websiteId,
|
|
815
|
+
...result
|
|
816
|
+
}),
|
|
817
|
+
() => ({ removed: true, alreadyRemoved: true, partyId, websiteId })
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/tools/opportunities.ts
|
|
822
|
+
import { z as z4 } from "zod";
|
|
823
|
+
var OpportunityValueSchema = z4.object({
|
|
824
|
+
amount: z4.number().nonnegative(),
|
|
825
|
+
currency: z4.string({
|
|
826
|
+
error: (iss) => iss.code === "invalid_type" && iss.input === void 0 ? "currency is required when amount is set (3-letter ISO 4217 code, e.g. 'USD', 'EUR', 'GBP')" : void 0
|
|
827
|
+
}).length(3).describe(
|
|
828
|
+
"ISO 4217 currency code (3 letters), e.g. 'GBP', 'USD', 'EUR'. Required when amount is set."
|
|
829
|
+
)
|
|
830
|
+
});
|
|
831
|
+
var searchOpportunitiesSchema = z4.object({
|
|
832
|
+
q: z4.string().optional().describe("Free-text search query"),
|
|
833
|
+
embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
834
|
+
page: z4.number().int().positive().optional().default(1),
|
|
835
|
+
perPage: z4.number().int().min(1).max(100).optional().default(25)
|
|
836
|
+
});
|
|
837
|
+
async function searchOpportunities(input) {
|
|
838
|
+
const path = input.q ? "/opportunities/search" : "/opportunities";
|
|
839
|
+
const { data, nextPage } = await capsuleGet(path, {
|
|
840
|
+
q: input.q,
|
|
841
|
+
embed: input.embed,
|
|
842
|
+
page: input.page,
|
|
843
|
+
perPage: input.perPage
|
|
844
|
+
});
|
|
845
|
+
return { ...data, nextPage };
|
|
846
|
+
}
|
|
847
|
+
var getOpportunitySchema = z4.object({
|
|
848
|
+
id: z4.number().int().positive(),
|
|
849
|
+
embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
850
|
+
});
|
|
851
|
+
async function getOpportunity(input) {
|
|
852
|
+
const { data } = await capsuleGet(`/opportunities/${input.id}`, {
|
|
853
|
+
embed: input.embed
|
|
854
|
+
});
|
|
855
|
+
return data;
|
|
856
|
+
}
|
|
857
|
+
var getOpportunitiesSchema = z4.object({
|
|
858
|
+
ids: z4.array(z4.number().int().positive()).min(1).max(10).describe("Array of opportunity IDs (1\u201310). Capsule caps batch fetches at 10."),
|
|
859
|
+
embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
860
|
+
});
|
|
861
|
+
async function getOpportunities(input) {
|
|
862
|
+
const { data } = await capsuleGet(
|
|
863
|
+
`/opportunities/${input.ids.join(",")}`,
|
|
864
|
+
{ embed: input.embed }
|
|
865
|
+
);
|
|
866
|
+
return data;
|
|
867
|
+
}
|
|
868
|
+
var createOpportunitySchema = z4.object({
|
|
869
|
+
name: z4.string().min(1),
|
|
870
|
+
partyId: z4.number().int().positive().describe("ID of the party this opportunity belongs to"),
|
|
871
|
+
milestoneId: z4.number().int().positive().describe(
|
|
872
|
+
"ID of the pipeline milestone to place this opportunity at. The milestone implicitly determines the pipeline \u2014 there is no separate pipelineId parameter. Discover via list_pipelines / list_milestones."
|
|
873
|
+
),
|
|
874
|
+
description: z4.string().optional(),
|
|
875
|
+
value: OpportunityValueSchema.optional(),
|
|
876
|
+
expectedCloseOn: z4.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
877
|
+
probability: z4.number().int().min(0).max(100).optional(),
|
|
878
|
+
ownerId: z4.number().int().positive().optional().describe(
|
|
879
|
+
"Assign to user ID. Defaults to the API-token owner when omitted \u2014 note that opportunities do NOT inherit owner from the linked party, even though one might expect it. Once set, this connector cannot clear the owner back to null (use Capsule's web UI). Discover IDs via list_users."
|
|
880
|
+
)
|
|
881
|
+
});
|
|
882
|
+
async function createOpportunity(input) {
|
|
883
|
+
const { partyId, milestoneId, ownerId, ...rest } = input;
|
|
884
|
+
const body = {
|
|
885
|
+
...rest,
|
|
886
|
+
party: { id: partyId },
|
|
887
|
+
milestone: { id: milestoneId }
|
|
888
|
+
};
|
|
889
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
890
|
+
return capsulePost("/opportunities", { opportunity: body });
|
|
891
|
+
}
|
|
892
|
+
var updateOpportunitySchema = z4.object({
|
|
893
|
+
id: z4.number().int().positive(),
|
|
894
|
+
name: z4.string().min(1).optional(),
|
|
895
|
+
milestoneId: z4.number().int().positive().optional().describe(
|
|
896
|
+
"Move the opportunity to this milestone. Side effects depend on the target: closing milestones (Won/Lost) auto-set `closedOn` to today and `probability` to the milestone default (100/0), preserving `lastOpenMilestone` as the previous open stage; moving back to an open milestone clears `closedOn` and re-applies the milestone's default probability (Won/Lost is reversible \u2014 no separate reopen tool). WARNING: Capsule does NOT validate that the new milestone belongs to the opportunity's current pipeline. Passing a milestoneId from a different pipeline silently relocates the opportunity across pipelines, and `lastOpenMilestone` may then reference a milestone in the previous pipeline. Verify against the opportunity's current pipeline (read the opp first, list its pipeline's milestones via list_milestones) before passing a cross-pipeline id."
|
|
897
|
+
),
|
|
898
|
+
description: z4.string().optional(),
|
|
899
|
+
value: OpportunityValueSchema.optional(),
|
|
900
|
+
expectedCloseOn: z4.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
901
|
+
probability: z4.number().int().min(0).max(100).optional().describe(
|
|
902
|
+
"Win probability 0\u2013100. On an open milestone this overrides the milestone's default probability. CANNOT be set in the same call as a closing milestone (Won/Lost) \u2014 Capsule processes the milestone change first, the opportunity becomes closed, then the probability update is rejected as edit-on-closed-opp with 422 'probability can be updated only for open opportunity'. To close an opportunity, leave probability out of the call: it auto-snaps to 100% (Won) or 0% (Lost)."
|
|
903
|
+
),
|
|
904
|
+
lostReasonId: z4.number().int().positive().optional().describe(
|
|
905
|
+
"Reason the opportunity was lost. Only meaningful when transitioning to a Lost milestone \u2014 Capsule silently drops it for other milestones. Without this set, a connector-driven Lost-close leaves `lostReason: null`. Discover IDs via list_lostreasons."
|
|
906
|
+
),
|
|
907
|
+
ownerId: z4.number().int().positive().optional().describe(
|
|
908
|
+
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
|
|
909
|
+
),
|
|
910
|
+
fields: z4.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
|
|
911
|
+
});
|
|
912
|
+
async function updateOpportunity(input) {
|
|
913
|
+
const { id, milestoneId, ownerId, lostReasonId, fields, ...rest } = input;
|
|
914
|
+
const body = {};
|
|
915
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
916
|
+
if (v !== void 0) body[k] = v;
|
|
917
|
+
}
|
|
918
|
+
if (milestoneId) body["milestone"] = { id: milestoneId };
|
|
919
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
920
|
+
if (lostReasonId) body["lostReason"] = { id: lostReasonId };
|
|
921
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
922
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
923
|
+
return capsulePut(`/opportunities/${id}`, {
|
|
924
|
+
opportunity: body
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
var deleteOpportunitySchema = z4.object({
|
|
928
|
+
id: z4.number().int().positive(),
|
|
929
|
+
confirm: confirmFlag().describe(
|
|
930
|
+
"Must be set to true. Permanently deletes the opportunity. Irreversible."
|
|
931
|
+
)
|
|
932
|
+
});
|
|
933
|
+
async function deleteOpportunity(input) {
|
|
934
|
+
if (input.confirm !== true) {
|
|
935
|
+
throw new Error("delete_opportunity requires confirm: true");
|
|
936
|
+
}
|
|
937
|
+
return idempotent(
|
|
938
|
+
() => capsuleDelete(`/opportunities/${input.id}`),
|
|
939
|
+
() => ({ deleted: true, alreadyDeleted: false, id: input.id }),
|
|
940
|
+
() => ({ deleted: true, alreadyDeleted: true, id: input.id })
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/tools/projects.ts
|
|
945
|
+
import { z as z5 } from "zod";
|
|
946
|
+
var listProjectsSchema = z5.object({
|
|
947
|
+
status: z5.enum(["OPEN", "CLOSED"]).optional(),
|
|
948
|
+
embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
949
|
+
page: z5.number().int().positive().optional().default(1),
|
|
950
|
+
perPage: z5.number().int().min(1).max(100).optional().default(25)
|
|
951
|
+
});
|
|
952
|
+
async function listProjects(input) {
|
|
953
|
+
const { data, nextPage } = await capsuleGet("/kases", {
|
|
954
|
+
status: input.status,
|
|
955
|
+
embed: input.embed,
|
|
956
|
+
page: input.page,
|
|
957
|
+
perPage: input.perPage
|
|
958
|
+
});
|
|
959
|
+
return { ...data, nextPage };
|
|
960
|
+
}
|
|
961
|
+
var getProjectSchema = z5.object({
|
|
962
|
+
id: z5.number().int().positive(),
|
|
963
|
+
embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
964
|
+
});
|
|
965
|
+
async function getProject(input) {
|
|
966
|
+
const { data } = await capsuleGet(`/kases/${input.id}`, {
|
|
967
|
+
embed: input.embed
|
|
968
|
+
});
|
|
969
|
+
return data;
|
|
970
|
+
}
|
|
971
|
+
var getProjectsSchema = z5.object({
|
|
972
|
+
ids: z5.array(z5.number().int().positive()).min(1).max(10).describe("Array of project IDs (1\u201310). Capsule caps batch fetches at 10."),
|
|
973
|
+
embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
974
|
+
});
|
|
975
|
+
async function getProjects(input) {
|
|
976
|
+
const { data } = await capsuleGet(`/kases/${input.ids.join(",")}`, {
|
|
977
|
+
embed: input.embed
|
|
978
|
+
});
|
|
979
|
+
return data;
|
|
980
|
+
}
|
|
981
|
+
var createProjectSchema = z5.object({
|
|
982
|
+
name: z5.string().min(1),
|
|
983
|
+
partyId: z5.number().int().positive().describe("ID of the party linked to this project"),
|
|
984
|
+
description: z5.string().optional(),
|
|
985
|
+
status: z5.enum(["OPEN", "CLOSED"]).optional().describe("Defaults to OPEN when omitted."),
|
|
986
|
+
ownerId: z5.number().int().positive().optional().describe(
|
|
987
|
+
"Assign to user ID. Defaults to the API-token owner when omitted, same as create_party / create_opportunity / create_task. NOTE: some Capsule tenants configure board-level **automation rules** that mutate `owner` (and `team`) on project creation \u2014 e.g. an automation that clears `owner` when a project enters a particular board. If you observe a project landing with unexpected `owner: null` after a create_project with `ownerId`, check the target board's automation configuration. Capsule's API itself does not drop `ownerId` when `stageId` is also supplied."
|
|
988
|
+
),
|
|
989
|
+
teamId: z5.number().int().positive().optional().describe(
|
|
990
|
+
"Assign to team ID (discover via list_teams). Capsule projects must always have at least one of {owner, team} set \u2014 Capsule returns 422 'owner or team is required' otherwise. Three ownership shapes are valid: owner alone, team alone, or owner+team (the user must be a member of the team \u2014 users can belong to multiple teams; 422 'owner is not a member of the team' otherwise). Tenant-specific board automations may set the team field on project creation (e.g. 'when project enters board X, set team to T'). If you observe a team set despite omitting `teamId`, check the target board's automation rules."
|
|
991
|
+
),
|
|
992
|
+
stageId: z5.number().int().positive().optional().describe(
|
|
993
|
+
"Stage (board column) to place the project on. Discover IDs via list_stages \u2014 each stage belongs to one Board, so picking a stageId implicitly picks the board. If omitted, the project is created with no stage assignment (and won't appear on any board). NOTE: tenant-specific board automation rules may run on project creation and mutate `owner` / `team` fields. See `create_project.ownerId` / `create_project.teamId` for the automation caveat. Capsule's create endpoint itself preserves the `ownerId` / `teamId` you supply \u2014 any clearing you observe traces to board automations, not the API."
|
|
994
|
+
),
|
|
995
|
+
expectedCloseOn: z5.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD")
|
|
996
|
+
});
|
|
997
|
+
async function createProject(input) {
|
|
998
|
+
const { partyId, ownerId, teamId, status, stageId, ...rest } = input;
|
|
999
|
+
const body = {
|
|
1000
|
+
...rest,
|
|
1001
|
+
status: status ?? "OPEN",
|
|
1002
|
+
party: { id: partyId }
|
|
1003
|
+
};
|
|
1004
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
1005
|
+
if (teamId) body["team"] = { id: teamId };
|
|
1006
|
+
if (stageId) body["stage"] = stageId;
|
|
1007
|
+
return capsulePost("/kases", { kase: body });
|
|
1008
|
+
}
|
|
1009
|
+
var updateProjectSchema = z5.object({
|
|
1010
|
+
id: z5.number().int().positive(),
|
|
1011
|
+
name: z5.string().min(1).optional(),
|
|
1012
|
+
description: z5.string().optional(),
|
|
1013
|
+
status: z5.enum(["OPEN", "CLOSED"]).optional(),
|
|
1014
|
+
ownerId: z5.number().int().positive().nullable().optional().describe(
|
|
1015
|
+
"Reassign owner: pass a user ID to set, or `null` to unassign (matches the 'Unassign' option in Capsule's web UI). When you supply `ownerId` and omit `teamId` and/or `stageId`, the connector fetches the project's current omitted fields and includes them in the PUT body \u2014 this preserves them across the owner change (without it, Capsule's PUT would clear team; stage carry is defensive against the symmetric clear). Supply `teamId` and/or `stageId` explicitly on the same call to change them instead. `teamId: null` clears the team as part of an owner change. Constraints (Capsule enforces, 422 on violation): owner must be a member of the team if both are set; a project must always have at least one of {owner, team} set (cannot clear both)."
|
|
1016
|
+
),
|
|
1017
|
+
teamId: z5.number().int().positive().nullable().optional().describe(
|
|
1018
|
+
"Reassign team: pass a team ID (discover via list_teams) to set, or `null` to unassign. Capsule preserves the existing owner across a team change (server-side), so `update_project { teamId }` alone is safe \u2014 the owner is carried through. Owner must be a member of the new team or Capsule returns 422 'owner is not a member of the team'. A project must always have at least one of {owner, team} set \u2014 `teamId: null` on a project whose owner is already null returns 422 'owner or team is required'."
|
|
1019
|
+
),
|
|
1020
|
+
stageId: z5.number().int().positive().optional().describe(
|
|
1021
|
+
"Move the project to this stage (board column). Discover IDs via list_stages. Owner and team are preserved across stage-only updates (Capsule's PUT semantic). WARNING (cross-board): Capsule does NOT validate that the new stage belongs to the project's current board \u2014 passing a stageId from a different board silently relocates the project across boards. Team and other board-derived defaults are NOT updated to match the new board. Verify against the project's current board (read the project first, list its board's stages) before passing a cross-board id."
|
|
1022
|
+
),
|
|
1023
|
+
expectedCloseOn: z5.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
1024
|
+
fields: z5.array(CustomFieldWriteSchema).optional().describe(
|
|
1025
|
+
fieldsArrayDescriptor("get_project") + " Project-specific: setting a field whose definition lives under a 'data tag' populates the row's internal tagId but does NOT auto-add the data tag to the project's tags array \u2014 use add_tag explicitly if you want it visible via embed=tags."
|
|
1026
|
+
)
|
|
1027
|
+
});
|
|
1028
|
+
async function updateProject(input) {
|
|
1029
|
+
const { id, ownerId, teamId, stageId, fields, ...rest } = input;
|
|
1030
|
+
const body = {};
|
|
1031
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1032
|
+
if (v !== void 0) body[k] = v;
|
|
1033
|
+
}
|
|
1034
|
+
let resolvedTeamId = teamId;
|
|
1035
|
+
let resolvedStageId = stageId;
|
|
1036
|
+
if (ownerId !== void 0 && (teamId === void 0 || stageId === void 0)) {
|
|
1037
|
+
const { data } = await capsuleGet(`/kases/${id}`);
|
|
1038
|
+
if (teamId === void 0) {
|
|
1039
|
+
resolvedTeamId = data.kase?.team?.id ?? void 0;
|
|
1040
|
+
}
|
|
1041
|
+
if (stageId === void 0) {
|
|
1042
|
+
resolvedStageId = data.kase?.stage?.id ?? void 0;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (ownerId === null) body["owner"] = null;
|
|
1046
|
+
else if (ownerId !== void 0) body["owner"] = { id: ownerId };
|
|
1047
|
+
if (resolvedTeamId === null) body["team"] = null;
|
|
1048
|
+
else if (resolvedTeamId !== void 0) body["team"] = { id: resolvedTeamId };
|
|
1049
|
+
if (resolvedStageId) body["stage"] = resolvedStageId;
|
|
1050
|
+
const mappedFields = mapFieldsForBody(fields);
|
|
1051
|
+
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1052
|
+
return capsulePut(`/kases/${id}`, { kase: body });
|
|
1053
|
+
}
|
|
1054
|
+
var deleteProjectSchema = z5.object({
|
|
1055
|
+
id: z5.number().int().positive(),
|
|
1056
|
+
confirm: confirmFlag().describe(
|
|
1057
|
+
"Must be set to true. Permanently deletes the project (case). Consider update_project status='CLOSED' instead. Irreversible."
|
|
1058
|
+
)
|
|
1059
|
+
});
|
|
1060
|
+
async function deleteProject(input) {
|
|
1061
|
+
if (input.confirm !== true) {
|
|
1062
|
+
throw new Error("delete_project requires confirm: true");
|
|
1063
|
+
}
|
|
1064
|
+
return idempotent(
|
|
1065
|
+
() => capsuleDelete(`/kases/${input.id}`),
|
|
1066
|
+
() => ({ deleted: true, alreadyDeleted: false, id: input.id }),
|
|
1067
|
+
() => ({ deleted: true, alreadyDeleted: true, id: input.id })
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/tools/tasks.ts
|
|
1072
|
+
import { z as z6 } from "zod";
|
|
1073
|
+
var listTasksSchema = z6.object({
|
|
1074
|
+
// Note: Capsule has a third internal status `PENDING` (a task that's
|
|
1075
|
+
// part of an active track but not yet "open"), but it can only be
|
|
1076
|
+
// reached via track machinery — it is NOT directly settable by
|
|
1077
|
+
// /tasks PUT, and a list filter for it returns the same as OPEN
|
|
1078
|
+
// anyway. We expose only the two values that are actually filterable
|
|
1079
|
+
// by the v2 API.
|
|
1080
|
+
status: z6.enum(["OPEN", "COMPLETED"]).optional().describe(
|
|
1081
|
+
"Defaults to OPEN when omitted. Pass COMPLETED to filter to completed tasks, or 'OPEN' explicitly."
|
|
1082
|
+
),
|
|
1083
|
+
ownerId: z6.number().int().positive().optional().describe("Filter to tasks owned by this user ID"),
|
|
1084
|
+
page: z6.number().int().positive().optional().default(1),
|
|
1085
|
+
perPage: z6.number().int().min(1).max(100).optional().default(25)
|
|
1086
|
+
});
|
|
1087
|
+
async function listTasks(input) {
|
|
1088
|
+
const { data, nextPage } = await capsuleGet("/tasks", {
|
|
1089
|
+
// Default 'OPEN' applied here (not via zod .default()) so that
|
|
1090
|
+
// z.infer keeps `status` optional for callers that omit it.
|
|
1091
|
+
status: input.status ?? "OPEN",
|
|
1092
|
+
// Capsule's owner filter is the bare query param `owner`, not `ownerId`/`assignedToUserId`.
|
|
1093
|
+
owner: input.ownerId,
|
|
1094
|
+
page: input.page,
|
|
1095
|
+
perPage: input.perPage
|
|
1096
|
+
});
|
|
1097
|
+
return { ...data, nextPage };
|
|
1098
|
+
}
|
|
1099
|
+
var getTaskSchema = z6.object({
|
|
1100
|
+
id: z6.number().int().positive().describe("Task ID")
|
|
1101
|
+
});
|
|
1102
|
+
async function getTask(input) {
|
|
1103
|
+
const { data } = await capsuleGet(`/tasks/${input.id}`);
|
|
1104
|
+
return data;
|
|
1105
|
+
}
|
|
1106
|
+
var getTasksSchema = z6.object({
|
|
1107
|
+
ids: z6.array(z6.number().int().positive()).min(1).max(10).describe("Array of task IDs (1\u201310). Capsule caps batch fetches at 10.")
|
|
1108
|
+
});
|
|
1109
|
+
async function getTasks(input) {
|
|
1110
|
+
const { data } = await capsuleGet(`/tasks/${input.ids.join(",")}`);
|
|
1111
|
+
return data;
|
|
1112
|
+
}
|
|
1113
|
+
var createTaskSchema = z6.object({
|
|
1114
|
+
description: z6.string().min(1),
|
|
1115
|
+
dueOn: z6.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD"),
|
|
1116
|
+
dueTime: z6.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
|
|
1117
|
+
detail: z6.string().optional(),
|
|
1118
|
+
ownerId: z6.number().int().positive().optional().describe(
|
|
1119
|
+
"Assign to user ID. Defaults to the API-token owner when omitted. Once set, this connector cannot clear the owner back to null \u2014 use Capsule's web UI for that."
|
|
1120
|
+
),
|
|
1121
|
+
partyId: z6.number().int().positive().optional().describe("Link task to a party (mutually exclusive with opportunityId/projectId)"),
|
|
1122
|
+
opportunityId: z6.number().int().positive().optional().describe("Link task to an opportunity (mutually exclusive with partyId/projectId)"),
|
|
1123
|
+
projectId: z6.number().int().positive().optional().describe("Link task to a project (mutually exclusive with partyId/opportunityId)")
|
|
1124
|
+
});
|
|
1125
|
+
async function createTask(input) {
|
|
1126
|
+
const linked = [input.partyId, input.opportunityId, input.projectId].filter(Boolean);
|
|
1127
|
+
if (linked.length > 1) {
|
|
1128
|
+
throw new Error("Provide at most one of partyId, opportunityId, or projectId");
|
|
1129
|
+
}
|
|
1130
|
+
const { ownerId, partyId, opportunityId, projectId, ...rest } = input;
|
|
1131
|
+
const body = { ...rest };
|
|
1132
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
1133
|
+
if (partyId) body["party"] = { id: partyId };
|
|
1134
|
+
if (opportunityId) body["opportunity"] = { id: opportunityId };
|
|
1135
|
+
if (projectId) body["kase"] = { id: projectId };
|
|
1136
|
+
return capsulePost("/tasks", { task: body });
|
|
1137
|
+
}
|
|
1138
|
+
var updateTaskSchema = z6.object({
|
|
1139
|
+
id: z6.number().int().positive(),
|
|
1140
|
+
description: z6.string().min(1).optional(),
|
|
1141
|
+
dueOn: z6.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("YYYY-MM-DD"),
|
|
1142
|
+
dueTime: z6.string().regex(/^\d{2}:\d{2}$/).optional().describe("HH:MM in user's timezone"),
|
|
1143
|
+
detail: z6.string().optional(),
|
|
1144
|
+
// Capsule rejects direct sets of `PENDING` (which is a track-machinery
|
|
1145
|
+
// internal state) with 422 "cannot set task status to PENDING".
|
|
1146
|
+
// Only OPEN and COMPLETED are settable here.
|
|
1147
|
+
status: z6.enum(["OPEN", "COMPLETED"]).optional().describe(
|
|
1148
|
+
"Set to OPEN or COMPLETED. (PENDING exists internally for track-driven tasks but cannot be set directly via this tool \u2014 Capsule rejects it.) Setting status: OPEN on an already-open task is a true no-op (does not advance updatedAt)."
|
|
1149
|
+
),
|
|
1150
|
+
ownerId: z6.number().int().positive().optional().describe(
|
|
1151
|
+
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
|
|
1152
|
+
)
|
|
1153
|
+
});
|
|
1154
|
+
async function updateTask(input) {
|
|
1155
|
+
const { id, ownerId, ...rest } = input;
|
|
1156
|
+
const body = {};
|
|
1157
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
1158
|
+
if (v !== void 0) body[k] = v;
|
|
1159
|
+
}
|
|
1160
|
+
if (ownerId) body["owner"] = { id: ownerId };
|
|
1161
|
+
return capsulePut(`/tasks/${id}`, { task: body });
|
|
1162
|
+
}
|
|
1163
|
+
var completeTaskSchema = z6.object({
|
|
1164
|
+
id: z6.number().int().positive()
|
|
1165
|
+
});
|
|
1166
|
+
async function completeTask(input) {
|
|
1167
|
+
return capsulePut(`/tasks/${input.id}`, {
|
|
1168
|
+
task: { status: "COMPLETED" }
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
var deleteTaskSchema = z6.object({
|
|
1172
|
+
id: z6.number().int().positive(),
|
|
1173
|
+
confirm: confirmFlag().describe(
|
|
1174
|
+
"Must be set to true. Permanently deletes the task. To mark done without losing history use complete_task. Irreversible."
|
|
1175
|
+
)
|
|
1176
|
+
});
|
|
1177
|
+
async function deleteTask(input) {
|
|
1178
|
+
if (input.confirm !== true) {
|
|
1179
|
+
throw new Error("delete_task requires confirm: true");
|
|
1180
|
+
}
|
|
1181
|
+
return idempotent(
|
|
1182
|
+
() => capsuleDelete(`/tasks/${input.id}`),
|
|
1183
|
+
() => ({ deleted: true, alreadyDeleted: false, id: input.id }),
|
|
1184
|
+
() => ({ deleted: true, alreadyDeleted: true, id: input.id })
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/tools/entries.ts
|
|
1189
|
+
import { z as z7 } from "zod";
|
|
1190
|
+
var listEntriesPagination = {
|
|
1191
|
+
page: z7.number().int().positive().optional().default(1),
|
|
1192
|
+
perPage: z7.number().int().min(1).max(100).optional().default(25),
|
|
1193
|
+
embed: z7.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
|
|
1194
|
+
};
|
|
1195
|
+
var listPartyEntriesSchema = z7.object({
|
|
1196
|
+
partyId: z7.number().int().positive(),
|
|
1197
|
+
...listEntriesPagination
|
|
1198
|
+
});
|
|
1199
|
+
async function listPartyEntries(input) {
|
|
1200
|
+
const { data, nextPage } = await capsuleGet(
|
|
1201
|
+
`/parties/${input.partyId}/entries`,
|
|
1202
|
+
{ embed: input.embed, page: input.page, perPage: input.perPage }
|
|
1203
|
+
);
|
|
1204
|
+
return { ...data, nextPage };
|
|
1205
|
+
}
|
|
1206
|
+
var listOpportunityEntriesSchema = z7.object({
|
|
1207
|
+
opportunityId: z7.number().int().positive(),
|
|
1208
|
+
...listEntriesPagination
|
|
1209
|
+
});
|
|
1210
|
+
async function listOpportunityEntries(input) {
|
|
1211
|
+
const { data, nextPage } = await capsuleGet(
|
|
1212
|
+
`/opportunities/${input.opportunityId}/entries`,
|
|
1213
|
+
{ embed: input.embed, page: input.page, perPage: input.perPage }
|
|
1214
|
+
);
|
|
1215
|
+
return { ...data, nextPage };
|
|
1216
|
+
}
|
|
1217
|
+
var listProjectEntriesSchema = z7.object({
|
|
1218
|
+
projectId: z7.number().int().positive(),
|
|
1219
|
+
...listEntriesPagination
|
|
1220
|
+
});
|
|
1221
|
+
async function listProjectEntries(input) {
|
|
1222
|
+
const { data, nextPage } = await capsuleGet(
|
|
1223
|
+
`/kases/${input.projectId}/entries`,
|
|
1224
|
+
{ embed: input.embed, page: input.page, perPage: input.perPage }
|
|
1225
|
+
);
|
|
1226
|
+
return { ...data, nextPage };
|
|
1227
|
+
}
|
|
1228
|
+
var getEntrySchema = z7.object({
|
|
1229
|
+
id: z7.number().int().positive(),
|
|
1230
|
+
embed: z7.string().optional().describe(EMBED_ATTACHMENTS_PARTICIPANTS_DESCRIPTION)
|
|
1231
|
+
});
|
|
1232
|
+
async function getEntry(input) {
|
|
1233
|
+
const { data } = await capsuleGet(`/entries/${input.id}`, {
|
|
1234
|
+
embed: input.embed
|
|
1235
|
+
});
|
|
1236
|
+
return data;
|
|
1237
|
+
}
|
|
1238
|
+
var listEntriesSchema = z7.object({
|
|
1239
|
+
...listEntriesPagination
|
|
1240
|
+
});
|
|
1241
|
+
async function listEntries(input) {
|
|
1242
|
+
const { data, nextPage } = await capsuleGet("/entries", {
|
|
1243
|
+
embed: input.embed,
|
|
1244
|
+
page: input.page,
|
|
1245
|
+
perPage: input.perPage
|
|
1246
|
+
});
|
|
1247
|
+
return { ...data, nextPage };
|
|
1248
|
+
}
|
|
1249
|
+
var addNoteSchema = z7.object({
|
|
1250
|
+
content: z7.string().min(1).describe(
|
|
1251
|
+
"Note body text. Stored verbatim and treated as MARKDOWN \u2014 Capsule's web UI renders the markdown when displaying. Pass markdown source ('# Heading', '**bold**', '- bullet'), not HTML."
|
|
1252
|
+
),
|
|
1253
|
+
partyId: z7.number().int().positive().optional().describe("Link note to a party (mutually exclusive with opportunityId/projectId)"),
|
|
1254
|
+
opportunityId: z7.number().int().positive().optional().describe("Link note to an opportunity (mutually exclusive with partyId/projectId)"),
|
|
1255
|
+
projectId: z7.number().int().positive().optional().describe("Link note to a project (mutually exclusive with partyId/opportunityId)"),
|
|
1256
|
+
entryAt: z7.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/).optional().describe(
|
|
1257
|
+
"ISO-8601 timestamp for when this note actually happened (e.g. '2024-03-15T14:30:00Z'). Defaults to now. Use this for backdating historical notes when migrating from another system. `entryAt` is preserved across subsequent update_entry calls; only `updatedAt` advances on edits. Note attribution flows to the API-token owner \u2014 there is no way to record a note as authored by a different user via this connector (a `creatorId` parameter would enable audit-attribution spoofing on shared-connector deployments, so it is intentionally not exposed)."
|
|
1258
|
+
)
|
|
1259
|
+
});
|
|
1260
|
+
async function addNote(input) {
|
|
1261
|
+
const { content, partyId, opportunityId, projectId, entryAt } = input;
|
|
1262
|
+
const linked = [partyId, opportunityId, projectId].filter(Boolean);
|
|
1263
|
+
if (linked.length !== 1) {
|
|
1264
|
+
throw new Error("Provide exactly one of partyId, opportunityId, or projectId");
|
|
1265
|
+
}
|
|
1266
|
+
const body = { type: "note", content };
|
|
1267
|
+
if (partyId) body["party"] = { id: partyId };
|
|
1268
|
+
if (opportunityId) body["opportunity"] = { id: opportunityId };
|
|
1269
|
+
if (projectId) body["kase"] = { id: projectId };
|
|
1270
|
+
if (entryAt !== void 0) body["entryAt"] = entryAt;
|
|
1271
|
+
return capsulePost("/entries", { entry: body });
|
|
1272
|
+
}
|
|
1273
|
+
var updateEntrySchema = z7.object({
|
|
1274
|
+
id: z7.number().int().positive().describe("Entry ID to update"),
|
|
1275
|
+
content: z7.string().min(1).optional().describe(
|
|
1276
|
+
"New body text for the entry. For notes, this is the markdown content; for emails, the body. Provide only if you want to change it."
|
|
1277
|
+
),
|
|
1278
|
+
subject: z7.string().optional().describe(
|
|
1279
|
+
"New subject line. Mostly meaningful on email-type entries; on plain notes Capsule accepts the call (HTTP 200) but **does not store the subject and does not advance `updatedAt`** \u2014 a true no-op for inapplicable fields. `entryAt` (when the note was authored) is preserved across edits; `updatedAt` advances only when an applicable field actually changes. To sort/filter by 'when did this happen', use `entryAt`; for 'last touched', use `updatedAt`."
|
|
1280
|
+
)
|
|
1281
|
+
});
|
|
1282
|
+
async function updateEntry(input) {
|
|
1283
|
+
const { id, ...rest } = input;
|
|
1284
|
+
const body = {};
|
|
1285
|
+
if (rest.content !== void 0) body["content"] = rest.content;
|
|
1286
|
+
if (rest.subject !== void 0) body["subject"] = rest.subject;
|
|
1287
|
+
if (Object.keys(body).length === 0) {
|
|
1288
|
+
throw new Error("update_entry: provide at least one field to update (content or subject)");
|
|
1289
|
+
}
|
|
1290
|
+
return capsulePut(`/entries/${id}`, { entry: body });
|
|
1291
|
+
}
|
|
1292
|
+
var deleteEntrySchema = z7.object({
|
|
1293
|
+
id: z7.number().int().positive().describe("Entry (note/email/task-record) ID"),
|
|
1294
|
+
confirm: confirmFlag().describe(
|
|
1295
|
+
"Must be set to true. Permanently deletes the entry \u2014 use this to remove a note from a party/opportunity/project. Irreversible."
|
|
1296
|
+
)
|
|
1297
|
+
});
|
|
1298
|
+
async function deleteEntry(input) {
|
|
1299
|
+
if (input.confirm !== true) {
|
|
1300
|
+
throw new Error("delete_entry requires confirm: true");
|
|
1301
|
+
}
|
|
1302
|
+
return idempotent(
|
|
1303
|
+
() => capsuleDelete(`/entries/${input.id}`),
|
|
1304
|
+
() => ({ deleted: true, alreadyDeleted: false, id: input.id }),
|
|
1305
|
+
() => ({ deleted: true, alreadyDeleted: true, id: input.id })
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/tools/pipelines.ts
|
|
1310
|
+
import { z as z8 } from "zod";
|
|
1311
|
+
var paginationFields = {
|
|
1312
|
+
page: z8.number().int().positive().optional(),
|
|
1313
|
+
perPage: z8.number().int().min(1).max(100).optional()
|
|
1314
|
+
};
|
|
1315
|
+
var listPipelinesSchema = z8.object({ ...paginationFields });
|
|
1316
|
+
async function listPipelines(input) {
|
|
1317
|
+
const { data, nextPage } = await capsuleGet("/pipelines", {
|
|
1318
|
+
page: input.page ?? 1,
|
|
1319
|
+
perPage: input.perPage ?? 100
|
|
1320
|
+
});
|
|
1321
|
+
return { ...data, nextPage };
|
|
1322
|
+
}
|
|
1323
|
+
var listMilestonesSchema = z8.object({
|
|
1324
|
+
pipelineId: z8.number().int().positive(),
|
|
1325
|
+
...paginationFields
|
|
1326
|
+
});
|
|
1327
|
+
async function listMilestones(input) {
|
|
1328
|
+
const { data, nextPage } = await capsuleGet(
|
|
1329
|
+
`/pipelines/${input.pipelineId}/milestones`,
|
|
1330
|
+
{ page: input.page ?? 1, perPage: input.perPage ?? 100 }
|
|
1331
|
+
);
|
|
1332
|
+
return { ...data, nextPage };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/tools/boards.ts
|
|
1336
|
+
import { z as z9 } from "zod";
|
|
1337
|
+
var paginationFields2 = {
|
|
1338
|
+
page: z9.number().int().positive().optional(),
|
|
1339
|
+
perPage: z9.number().int().min(1).max(100).optional()
|
|
1340
|
+
};
|
|
1341
|
+
var listBoardsSchema = z9.object({ ...paginationFields2 });
|
|
1342
|
+
async function listBoards(input) {
|
|
1343
|
+
const { data, nextPage } = await capsuleGet("/boards", {
|
|
1344
|
+
page: input.page ?? 1,
|
|
1345
|
+
perPage: input.perPage ?? 100
|
|
1346
|
+
});
|
|
1347
|
+
return { ...data, nextPage };
|
|
1348
|
+
}
|
|
1349
|
+
var listStagesSchema = z9.object({
|
|
1350
|
+
boardId: z9.number().int().positive().optional().describe(
|
|
1351
|
+
"Optional. If provided, returns only the stages defined on that specific board (uses /boards/{id}/stages). Omit to get all stages across all boards in one call."
|
|
1352
|
+
),
|
|
1353
|
+
...paginationFields2
|
|
1354
|
+
});
|
|
1355
|
+
async function listStages(input) {
|
|
1356
|
+
const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
|
|
1357
|
+
const { data, nextPage } = await capsuleGet(path, {
|
|
1358
|
+
page: input.page ?? 1,
|
|
1359
|
+
perPage: input.perPage ?? 100
|
|
1360
|
+
});
|
|
1361
|
+
return { ...data, nextPage };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/tools/tags.ts
|
|
1365
|
+
import { z as z10 } from "zod";
|
|
1366
|
+
var TAG_LIST_PATH = {
|
|
1367
|
+
parties: "/parties/tags",
|
|
1368
|
+
opportunities: "/opportunities/tags",
|
|
1369
|
+
kases: "/kases/tags"
|
|
1370
|
+
};
|
|
1371
|
+
var ENTITY_TO_WRAPPER = {
|
|
1372
|
+
parties: "party",
|
|
1373
|
+
opportunities: "opportunity",
|
|
1374
|
+
kases: "kase"
|
|
1375
|
+
};
|
|
1376
|
+
var TagEntity = z10.enum(["parties", "opportunities", "kases"]).describe("Which entity type. Use 'kases' for projects (Capsule's legacy path name).");
|
|
1377
|
+
var listTagsSchema = z10.object({
|
|
1378
|
+
entity: z10.enum(["parties", "opportunities", "kases"]).describe("The resource type to list tags for"),
|
|
1379
|
+
page: z10.number().int().positive().optional(),
|
|
1380
|
+
perPage: z10.number().int().min(1).max(100).optional()
|
|
1381
|
+
});
|
|
1382
|
+
async function listTags(input) {
|
|
1383
|
+
const path = TAG_LIST_PATH[input.entity];
|
|
1384
|
+
const { data, nextPage } = await capsuleGet(path, {
|
|
1385
|
+
page: input.page ?? 1,
|
|
1386
|
+
perPage: input.perPage ?? 100
|
|
1387
|
+
});
|
|
1388
|
+
return { ...data, nextPage };
|
|
1389
|
+
}
|
|
1390
|
+
var addTagSchema = z10.object({
|
|
1391
|
+
entity: TagEntity,
|
|
1392
|
+
entityId: z10.number().int().positive().describe("The party/opportunity/kase id."),
|
|
1393
|
+
tagName: z10.string().min(1).describe(
|
|
1394
|
+
"Name of the tag to attach. Capsule resolves by name: if a tag with this name already exists in the tenant it is attached to the entity; if not, Capsule creates the tag and attaches it. Names are tenant-global. Capsule matches case-INSENSITIVELY when resolving (so 'VIP' and 'vip' attach the same tag), preserving the canonical casing from whichever variant was created first. To ensure consistent casing in your tag list, call list_tags first and reuse the exact name from there. Idempotent \u2014 re-attaching an already-attached tag is harmless."
|
|
1395
|
+
)
|
|
1396
|
+
});
|
|
1397
|
+
async function addTag(input) {
|
|
1398
|
+
const { entity, entityId, tagName } = input;
|
|
1399
|
+
const wrapper = ENTITY_TO_WRAPPER[entity];
|
|
1400
|
+
return capsulePut(`/${entity}/${entityId}`, {
|
|
1401
|
+
[wrapper]: { tags: [{ name: tagName }] }
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
var removeTagByIdSchema = z10.object({
|
|
1405
|
+
entity: TagEntity,
|
|
1406
|
+
entityId: z10.number().int().positive().describe("The party/opportunity/kase id."),
|
|
1407
|
+
tagId: z10.number().int().positive().describe(
|
|
1408
|
+
"The tag's id. Read via get_party / get_opportunity / get_project with embed='tags' \u2014 each tag entry in the response has an `id` field. list_tags returns the same ids for the same tags, so either source works; reading via embed first is the safer pattern because it confirms the tag is actually attached to this entity before you try to remove it (otherwise Capsule returns 422 'tag not found to delete'). Removing detaches the tag from this entity only; the tag definition itself persists in the tenant for other entities that share it."
|
|
1409
|
+
)
|
|
1410
|
+
});
|
|
1411
|
+
async function removeTagById(input) {
|
|
1412
|
+
const { entity, entityId, tagId } = input;
|
|
1413
|
+
const wrapper = ENTITY_TO_WRAPPER[entity];
|
|
1414
|
+
return idempotentWithResult(
|
|
1415
|
+
() => capsulePut(`/${entity}/${entityId}`, {
|
|
1416
|
+
[wrapper]: { tags: [{ id: tagId, _delete: true }] }
|
|
1417
|
+
}),
|
|
1418
|
+
(result) => ({
|
|
1419
|
+
removed: true,
|
|
1420
|
+
alreadyRemoved: false,
|
|
1421
|
+
entity,
|
|
1422
|
+
entityId,
|
|
1423
|
+
tagId,
|
|
1424
|
+
...result
|
|
1425
|
+
}),
|
|
1426
|
+
() => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
|
|
1427
|
+
// Tag detach uses PUT with _delete: true and 422s with "tag not
|
|
1428
|
+
// found to delete" on a not-attached tag, instead of the standard
|
|
1429
|
+
// 404. Other 422s with different wording still surface.
|
|
1430
|
+
isCapsuleTagNotFound
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/tools/users.ts
|
|
1435
|
+
import { z as z11 } from "zod";
|
|
1436
|
+
var listUsersSchema = z11.object({
|
|
1437
|
+
page: z11.number().int().positive().optional(),
|
|
1438
|
+
perPage: z11.number().int().min(1).max(100).optional()
|
|
1439
|
+
});
|
|
1440
|
+
async function listUsers(input) {
|
|
1441
|
+
const { data, nextPage } = await capsuleGet("/users", {
|
|
1442
|
+
page: input.page ?? 1,
|
|
1443
|
+
perPage: input.perPage ?? 100
|
|
1444
|
+
});
|
|
1445
|
+
return { ...data, nextPage };
|
|
1446
|
+
}
|
|
1447
|
+
var getCurrentUserSchema = z11.object({});
|
|
1448
|
+
async function getCurrentUser(_input) {
|
|
1449
|
+
const { data } = await capsuleGet("/users/current");
|
|
1450
|
+
return data;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// src/tools/filters.ts
|
|
1454
|
+
import { z as z12 } from "zod";
|
|
1455
|
+
var FilterConditionSchema = z12.object({
|
|
1456
|
+
field: z12.string().describe(
|
|
1457
|
+
"The Capsule filter-side field name (these differ from response field names \u2014 e.g. response.createdAt is filter-side 'addedOn', response.lastContactedAt is filter-side 'lastContactedOn'). Common: 'addedOn' (date created), 'updatedOn' (date last modified), 'lastContactedOn' (parties only), 'name', 'tag', 'owner', 'team', 'type' (parties: person|organisation), 'milestone' (opportunities), 'status' (opp/project: OPEN|CLOSED), 'closedOn' (opp/project), 'expectedCloseOn' (opp/project), 'hasTags', 'hasEmailAddress' (parties), 'isOpen', 'isStale' (opportunities), 'custom:{fieldId}'. Full per-entity list: https://developer.capsulecrm.com/v2/reference/filters"
|
|
1458
|
+
),
|
|
1459
|
+
operator: z12.string().describe(
|
|
1460
|
+
"The filter operator. Common: 'is', 'is not' (use value=null to test for null), 'contains', 'does not contain', 'is greater than', 'is less than', 'is within last' (date fields, value=integer days), 'is more than' (date fields, value=integer days ago), 'starts with', 'ends with'. Operator validity depends on the field's type."
|
|
1461
|
+
),
|
|
1462
|
+
value: z12.union([z12.string(), z12.number(), z12.boolean(), z12.null()]).describe(
|
|
1463
|
+
"The value to compare against. For 'is within last' on date fields, pass an integer number of days. For tag filters, pass the tag name (string) or tag id (number). For 'is not' null tests, pass null literally."
|
|
1464
|
+
)
|
|
1465
|
+
});
|
|
1466
|
+
var FilterInputSchema = z12.object({
|
|
1467
|
+
conditions: z12.array(FilterConditionSchema).min(1).describe(
|
|
1468
|
+
"Array of filter conditions. All conditions are ANDed together. To get newest records, use a date condition like {field: 'addedOn', operator: 'is within last', value: 7} and pick the highest-id row from the result (Capsule IDs are monotonic)."
|
|
1469
|
+
),
|
|
1470
|
+
embed: z12.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
1471
|
+
page: z12.number().int().positive().optional().default(1),
|
|
1472
|
+
perPage: z12.number().int().min(1).max(100).optional().default(25)
|
|
1473
|
+
});
|
|
1474
|
+
async function runFilter(entityPath, input) {
|
|
1475
|
+
const { data, nextPage } = await capsuleSearch(
|
|
1476
|
+
`/${entityPath}/filters/results`,
|
|
1477
|
+
{ filter: { conditions: input.conditions } },
|
|
1478
|
+
{
|
|
1479
|
+
page: input.page,
|
|
1480
|
+
perPage: input.perPage,
|
|
1481
|
+
embed: input.embed
|
|
1482
|
+
}
|
|
1483
|
+
);
|
|
1484
|
+
return { ...data, nextPage };
|
|
1485
|
+
}
|
|
1486
|
+
var filterPartiesSchema = FilterInputSchema;
|
|
1487
|
+
async function filterParties(input) {
|
|
1488
|
+
return runFilter("parties", input);
|
|
1489
|
+
}
|
|
1490
|
+
var filterOpportunitiesSchema = FilterInputSchema;
|
|
1491
|
+
async function filterOpportunities(input) {
|
|
1492
|
+
return runFilter(
|
|
1493
|
+
"opportunities",
|
|
1494
|
+
input
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
var filterProjectsSchema = FilterInputSchema;
|
|
1498
|
+
async function filterProjects(input) {
|
|
1499
|
+
return runFilter("kases", input);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/tools/metadata.ts
|
|
1503
|
+
import { z as z13 } from "zod";
|
|
1504
|
+
var paginationFields3 = {
|
|
1505
|
+
page: z13.number().int().positive().optional(),
|
|
1506
|
+
perPage: z13.number().int().min(1).max(100).optional().describe("Page size, max 100. Defaults to 100 for reference data.")
|
|
1507
|
+
};
|
|
1508
|
+
var listTeamsSchema = z13.object({ ...paginationFields3 });
|
|
1509
|
+
async function listTeams(input) {
|
|
1510
|
+
const { data, nextPage } = await capsuleGet("/teams", {
|
|
1511
|
+
page: input.page ?? 1,
|
|
1512
|
+
perPage: input.perPage ?? 100
|
|
1513
|
+
});
|
|
1514
|
+
return { ...data, nextPage };
|
|
1515
|
+
}
|
|
1516
|
+
var listLostReasonsSchema = z13.object({ ...paginationFields3 });
|
|
1517
|
+
async function listLostReasons(input) {
|
|
1518
|
+
const { data, nextPage } = await capsuleGet("/lostreasons", {
|
|
1519
|
+
page: input.page ?? 1,
|
|
1520
|
+
perPage: input.perPage ?? 100
|
|
1521
|
+
});
|
|
1522
|
+
return { ...data, nextPage };
|
|
1523
|
+
}
|
|
1524
|
+
var listActivityTypesSchema = z13.object({ ...paginationFields3 });
|
|
1525
|
+
async function listActivityTypes(input) {
|
|
1526
|
+
const { data, nextPage } = await capsuleGet("/activitytypes", {
|
|
1527
|
+
page: input.page ?? 1,
|
|
1528
|
+
perPage: input.perPage ?? 100
|
|
1529
|
+
});
|
|
1530
|
+
return { ...data, nextPage };
|
|
1531
|
+
}
|
|
1532
|
+
var getSiteSchema = z13.object({});
|
|
1533
|
+
async function getSite(_input) {
|
|
1534
|
+
const { data } = await capsuleGet("/site");
|
|
1535
|
+
return data;
|
|
1536
|
+
}
|
|
1537
|
+
var listTrackDefinitionsSchema = z13.object({ ...paginationFields3 });
|
|
1538
|
+
async function listTrackDefinitions(input) {
|
|
1539
|
+
const { data, nextPage } = await capsuleGet(
|
|
1540
|
+
"/trackdefinitions",
|
|
1541
|
+
{ page: input.page ?? 1, perPage: input.perPage ?? 100 }
|
|
1542
|
+
);
|
|
1543
|
+
return { ...data, nextPage };
|
|
1544
|
+
}
|
|
1545
|
+
var listCategoriesSchema = z13.object({ ...paginationFields3 });
|
|
1546
|
+
async function listCategories(input) {
|
|
1547
|
+
const { data, nextPage } = await capsuleGet("/categories", {
|
|
1548
|
+
page: input.page ?? 1,
|
|
1549
|
+
perPage: input.perPage ?? 100
|
|
1550
|
+
});
|
|
1551
|
+
return { ...data, nextPage };
|
|
1552
|
+
}
|
|
1553
|
+
var listGoalsSchema = z13.object({ ...paginationFields3 });
|
|
1554
|
+
async function listGoals(input) {
|
|
1555
|
+
const { data, nextPage } = await capsuleGet("/goals", {
|
|
1556
|
+
page: input.page ?? 1,
|
|
1557
|
+
perPage: input.perPage ?? 100
|
|
1558
|
+
});
|
|
1559
|
+
return { ...data, nextPage };
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// src/tools/audit.ts
|
|
1563
|
+
import { z as z14 } from "zod";
|
|
1564
|
+
var listEmployeesSchema = z14.object({
|
|
1565
|
+
partyId: z14.number().int().positive().describe(
|
|
1566
|
+
"The organisation's party id. Returns the people whose `organisation` field links to this party."
|
|
1567
|
+
),
|
|
1568
|
+
page: z14.number().int().positive().optional().default(1),
|
|
1569
|
+
perPage: z14.number().int().min(1).max(100).optional().default(25),
|
|
1570
|
+
embed: z14.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1571
|
+
});
|
|
1572
|
+
async function listEmployees(input) {
|
|
1573
|
+
const { data, nextPage } = await capsuleGet(
|
|
1574
|
+
`/parties/${input.partyId}/people`,
|
|
1575
|
+
{ page: input.page, perPage: input.perPage, embed: input.embed }
|
|
1576
|
+
);
|
|
1577
|
+
return { ...data, nextPage };
|
|
1578
|
+
}
|
|
1579
|
+
var DeletedSinceSchema = z14.string().describe(
|
|
1580
|
+
"REQUIRED. ISO-8601 timestamp; only deletions on or after this point are returned. Example: '2026-01-01T00:00:00Z'."
|
|
1581
|
+
);
|
|
1582
|
+
var DeletedPagination = {
|
|
1583
|
+
since: DeletedSinceSchema,
|
|
1584
|
+
page: z14.number().int().positive().optional().default(1),
|
|
1585
|
+
perPage: z14.number().int().min(1).max(100).optional().default(25)
|
|
1586
|
+
};
|
|
1587
|
+
var listDeletedPartiesSchema = z14.object(DeletedPagination);
|
|
1588
|
+
async function listDeletedParties(input) {
|
|
1589
|
+
const { data, nextPage } = await capsuleGet("/parties/deleted", {
|
|
1590
|
+
since: input.since,
|
|
1591
|
+
page: input.page,
|
|
1592
|
+
perPage: input.perPage
|
|
1593
|
+
});
|
|
1594
|
+
return { ...data, nextPage };
|
|
1595
|
+
}
|
|
1596
|
+
var listDeletedOpportunitiesSchema = z14.object(DeletedPagination);
|
|
1597
|
+
async function listDeletedOpportunities(input) {
|
|
1598
|
+
const { data, nextPage } = await capsuleGet("/opportunities/deleted", {
|
|
1599
|
+
since: input.since,
|
|
1600
|
+
page: input.page,
|
|
1601
|
+
perPage: input.perPage
|
|
1602
|
+
});
|
|
1603
|
+
return { ...data, nextPage };
|
|
1604
|
+
}
|
|
1605
|
+
var listDeletedProjectsSchema = z14.object(DeletedPagination);
|
|
1606
|
+
async function listDeletedProjects(input) {
|
|
1607
|
+
const { data, nextPage } = await capsuleGet("/kases/deleted", {
|
|
1608
|
+
since: input.since,
|
|
1609
|
+
page: input.page,
|
|
1610
|
+
perPage: input.perPage
|
|
1611
|
+
});
|
|
1612
|
+
return { ...data, nextPage };
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// src/tools/relationships.ts
|
|
1616
|
+
import { z as z15 } from "zod";
|
|
1617
|
+
var RelationshipEntity = z15.enum(["opportunities", "kases"]).describe("Which entity has the additional-party links. Use 'kases' for projects.");
|
|
1618
|
+
var listAdditionalPartiesSchema = z15.object({
|
|
1619
|
+
entity: RelationshipEntity,
|
|
1620
|
+
entityId: z15.number().int().positive().describe("ID of the opportunity or project."),
|
|
1621
|
+
embed: z15.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
1622
|
+
page: z15.number().int().positive().optional().default(1),
|
|
1623
|
+
perPage: z15.number().int().min(1).max(100).optional().default(25)
|
|
1624
|
+
});
|
|
1625
|
+
async function listAdditionalParties(input) {
|
|
1626
|
+
const { data, nextPage } = await capsuleGet(
|
|
1627
|
+
`/${input.entity}/${input.entityId}/parties`,
|
|
1628
|
+
{ embed: input.embed, page: input.page, perPage: input.perPage }
|
|
1629
|
+
);
|
|
1630
|
+
return { ...data, nextPage };
|
|
1631
|
+
}
|
|
1632
|
+
var addAdditionalPartySchema = z15.object({
|
|
1633
|
+
entity: RelationshipEntity,
|
|
1634
|
+
entityId: z15.number().int().positive(),
|
|
1635
|
+
partyId: z15.number().int().positive().describe("ID of the party (person or organisation) to link as an additional party.")
|
|
1636
|
+
});
|
|
1637
|
+
async function addAdditionalParty(input) {
|
|
1638
|
+
try {
|
|
1639
|
+
await capsulePostNoContent(`/${input.entity}/${input.entityId}/parties/${input.partyId}`);
|
|
1640
|
+
return {
|
|
1641
|
+
linked: true,
|
|
1642
|
+
alreadyLinked: false,
|
|
1643
|
+
entity: input.entity,
|
|
1644
|
+
entityId: input.entityId,
|
|
1645
|
+
partyId: input.partyId
|
|
1646
|
+
};
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
if (err instanceof CapsuleApiError && err.status === 422) {
|
|
1649
|
+
const msg = err.message.toLowerCase();
|
|
1650
|
+
if (msg.includes("already a contact") || msg.includes("already related")) {
|
|
1651
|
+
return {
|
|
1652
|
+
linked: true,
|
|
1653
|
+
alreadyLinked: true,
|
|
1654
|
+
entity: input.entity,
|
|
1655
|
+
entityId: input.entityId,
|
|
1656
|
+
partyId: input.partyId
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
throw err;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
var removeAdditionalPartySchema = z15.object({
|
|
1664
|
+
entity: RelationshipEntity,
|
|
1665
|
+
entityId: z15.number().int().positive(),
|
|
1666
|
+
partyId: z15.number().int().positive(),
|
|
1667
|
+
confirm: confirmFlag().describe(
|
|
1668
|
+
"Must be set to true. Removes the link between the entity and the additional party. The party itself is not deleted. Reversible by re-adding the link."
|
|
1669
|
+
)
|
|
1670
|
+
});
|
|
1671
|
+
async function removeAdditionalParty(input) {
|
|
1672
|
+
if (input.confirm !== true) {
|
|
1673
|
+
throw new Error("remove_additional_party requires confirm: true");
|
|
1674
|
+
}
|
|
1675
|
+
return idempotent(
|
|
1676
|
+
() => capsuleDelete(`/${input.entity}/${input.entityId}/parties/${input.partyId}`),
|
|
1677
|
+
() => ({
|
|
1678
|
+
removed: true,
|
|
1679
|
+
alreadyRemoved: false,
|
|
1680
|
+
entity: input.entity,
|
|
1681
|
+
entityId: input.entityId,
|
|
1682
|
+
partyId: input.partyId
|
|
1683
|
+
}),
|
|
1684
|
+
() => ({
|
|
1685
|
+
removed: true,
|
|
1686
|
+
alreadyRemoved: true,
|
|
1687
|
+
entity: input.entity,
|
|
1688
|
+
entityId: input.entityId,
|
|
1689
|
+
partyId: input.partyId
|
|
1690
|
+
})
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
var listAssociatedProjectsSchema = z15.object({
|
|
1694
|
+
opportunityId: z15.number().int().positive(),
|
|
1695
|
+
embed: z15.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
1696
|
+
page: z15.number().int().positive().optional().default(1),
|
|
1697
|
+
perPage: z15.number().int().min(1).max(100).optional().default(25)
|
|
1698
|
+
});
|
|
1699
|
+
async function listAssociatedProjects(input) {
|
|
1700
|
+
const { data, nextPage } = await capsuleGet(
|
|
1701
|
+
`/opportunities/${input.opportunityId}/kases`,
|
|
1702
|
+
{ embed: input.embed, page: input.page, perPage: input.perPage }
|
|
1703
|
+
);
|
|
1704
|
+
return { ...data, nextPage };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/tools/custom-fields.ts
|
|
1708
|
+
import { z as z16 } from "zod";
|
|
1709
|
+
var CustomFieldEntity = z16.enum(["parties", "opportunities", "kases"]).describe("Which entity type's custom field schema to inspect. Use 'kases' for projects.");
|
|
1710
|
+
var listCustomFieldsSchema = z16.object({
|
|
1711
|
+
entity: CustomFieldEntity
|
|
1712
|
+
});
|
|
1713
|
+
async function listCustomFields(input) {
|
|
1714
|
+
const { data } = await capsuleGet(
|
|
1715
|
+
`/${input.entity}/fields/definitions`
|
|
1716
|
+
);
|
|
1717
|
+
return data;
|
|
1718
|
+
}
|
|
1719
|
+
var getCustomFieldSchema = z16.object({
|
|
1720
|
+
entity: CustomFieldEntity,
|
|
1721
|
+
fieldId: z16.number().int().positive().describe("Custom field definition id.")
|
|
1722
|
+
});
|
|
1723
|
+
async function getCustomField(input) {
|
|
1724
|
+
const { data } = await capsuleGet(
|
|
1725
|
+
`/${input.entity}/fields/definitions/${input.fieldId}`
|
|
1726
|
+
);
|
|
1727
|
+
return data;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/tools/tracks.ts
|
|
1731
|
+
import { z as z17 } from "zod";
|
|
1732
|
+
var TrackEntity = z17.enum(["parties", "opportunities", "kases"]).describe("Use 'kases' for projects.");
|
|
1733
|
+
var listEntityTracksSchema = z17.object({
|
|
1734
|
+
entity: TrackEntity,
|
|
1735
|
+
entityId: z17.number().int().positive()
|
|
1736
|
+
});
|
|
1737
|
+
async function listEntityTracks(input) {
|
|
1738
|
+
const { data } = await capsuleGet(
|
|
1739
|
+
`/${input.entity}/${input.entityId}/tracks`
|
|
1740
|
+
);
|
|
1741
|
+
return data;
|
|
1742
|
+
}
|
|
1743
|
+
var showTrackSchema = z17.object({
|
|
1744
|
+
trackId: z17.number().int().positive()
|
|
1745
|
+
});
|
|
1746
|
+
async function showTrack(input) {
|
|
1747
|
+
const { data } = await capsuleGet(`/tracks/${input.trackId}`);
|
|
1748
|
+
return data;
|
|
1749
|
+
}
|
|
1750
|
+
var applyTrackSchema = z17.object({
|
|
1751
|
+
entity: z17.enum(["opportunities", "kases"]).describe("Which entity to apply the track to. Use 'kases' for projects."),
|
|
1752
|
+
entityId: z17.number().int().positive(),
|
|
1753
|
+
trackDefinitionId: z17.number().int().positive().describe(
|
|
1754
|
+
"The trackDefinition to apply (from list_track_definitions). Auto-creates task definitions on the target entity per the track's rules."
|
|
1755
|
+
),
|
|
1756
|
+
startDate: z17.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
|
|
1757
|
+
"Optional ISO-8601 date (YYYY-MM-DD) the track should start from \u2014 drives task due-date calculations (each task's `dueOn` is computed as startDate + the track-definition's `daysAfter` offset). Defaults to today if omitted. Useful for scheduling a renewal-queue track against a future contract end-date, or backfilling tracks for historical projects."
|
|
1758
|
+
)
|
|
1759
|
+
});
|
|
1760
|
+
async function applyTrack(input) {
|
|
1761
|
+
const target = input.entity === "opportunities" ? "opportunity" : "kase";
|
|
1762
|
+
const track = {
|
|
1763
|
+
definition: { id: input.trackDefinitionId },
|
|
1764
|
+
[target]: { id: input.entityId }
|
|
1765
|
+
};
|
|
1766
|
+
if (input.startDate !== void 0) track["trackDateOn"] = input.startDate;
|
|
1767
|
+
return capsulePost("/tracks", { track });
|
|
1768
|
+
}
|
|
1769
|
+
var updateTrackSchema = z17.object({
|
|
1770
|
+
trackId: z17.number().int().positive(),
|
|
1771
|
+
fields: z17.record(z17.string(), z17.unknown()).describe(
|
|
1772
|
+
"Object of fields to update on the track. Capsule's PUT semantics are partial \u2014 only the fields you provide are changed. Common: { complete: true } to mark a track completed. Capsule rejects unknown keys; consult Capsule's docs for the full updatable set."
|
|
1773
|
+
)
|
|
1774
|
+
});
|
|
1775
|
+
async function updateTrack(input) {
|
|
1776
|
+
if (Object.keys(input.fields).length === 0) {
|
|
1777
|
+
throw new Error("update_track: provide at least one field in `fields`");
|
|
1778
|
+
}
|
|
1779
|
+
return capsulePut(`/tracks/${input.trackId}`, {
|
|
1780
|
+
track: input.fields
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
var removeTrackSchema = z17.object({
|
|
1784
|
+
trackId: z17.number().int().positive(),
|
|
1785
|
+
confirm: confirmFlag().describe(
|
|
1786
|
+
"Must be set to true. Removes the track instance from its entity. **Capsule also deletes the auto-tasks the track created when it was applied** \u2014 they go with the track and become unreachable (404 on GET /tasks/{id}, gone from list_tasks on the parent entity). If you need any of those tasks to outlive the track, copy their content into fresh tasks (or use the web UI) before calling remove_track."
|
|
1787
|
+
)
|
|
1788
|
+
});
|
|
1789
|
+
async function removeTrack(input) {
|
|
1790
|
+
if (input.confirm !== true) {
|
|
1791
|
+
throw new Error("remove_track requires confirm: true");
|
|
1792
|
+
}
|
|
1793
|
+
return idempotent(
|
|
1794
|
+
() => capsuleDelete(`/tracks/${input.trackId}`),
|
|
1795
|
+
() => ({ removed: true, alreadyRemoved: false, trackId: input.trackId }),
|
|
1796
|
+
() => ({ removed: true, alreadyRemoved: true, trackId: input.trackId })
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/tools/attachments.ts
|
|
1801
|
+
import { z as z18 } from "zod";
|
|
1802
|
+
var DEFAULT_MAX_SIZE_BYTES = 5 * 1024 * 1024;
|
|
1803
|
+
var HARD_MAX_SIZE_BYTES = 25 * 1024 * 1024;
|
|
1804
|
+
var HARD_MAX_BASE64_CHARS = Math.ceil(HARD_MAX_SIZE_BYTES / 3) * 4;
|
|
1805
|
+
var getAttachmentSchema = z18.object({
|
|
1806
|
+
id: z18.number().int().positive().describe("Attachment ID."),
|
|
1807
|
+
maxSizeBytes: z18.number().int().positive().max(HARD_MAX_SIZE_BYTES).optional().describe(
|
|
1808
|
+
`Refuse to return content over this size (default ${DEFAULT_MAX_SIZE_BYTES} bytes \u2248 5MB; max ${HARD_MAX_SIZE_BYTES} bytes \u2248 25MB). Files exceeding the cap return metadata only with a 'truncated: true' flag.`
|
|
1809
|
+
)
|
|
1810
|
+
});
|
|
1811
|
+
async function getAttachment(input) {
|
|
1812
|
+
const cap = input.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
|
1813
|
+
const { contentType, buffer, truncated, sizeBytes } = await capsuleGetBinary(
|
|
1814
|
+
`/attachments/${input.id}`,
|
|
1815
|
+
cap
|
|
1816
|
+
);
|
|
1817
|
+
if (truncated) {
|
|
1818
|
+
return { contentType, buffer: Buffer.alloc(0), truncated: true, sizeBytes };
|
|
1819
|
+
}
|
|
1820
|
+
return { contentType, buffer, sizeBytes };
|
|
1821
|
+
}
|
|
1822
|
+
var uploadAttachmentSchema = z18.object({
|
|
1823
|
+
filename: z18.string().min(1).describe(
|
|
1824
|
+
"Filename Capsule should record (e.g. 'contract.pdf'). Capsule does NOT validate consistency between filename, contentType, and the actual bytes \u2014 a typo in either is accepted and the file is stored as labelled."
|
|
1825
|
+
),
|
|
1826
|
+
contentType: z18.string().min(1).describe(
|
|
1827
|
+
"MIME type of the file (e.g. 'application/pdf', 'image/png', 'text/plain'). Trusted by Capsule verbatim; not cross-checked against `filename` or the actual bytes."
|
|
1828
|
+
),
|
|
1829
|
+
dataBase64: z18.string().min(1).max(HARD_MAX_BASE64_CHARS).describe(
|
|
1830
|
+
"File contents, base64-encoded. Decoded server-side and uploaded as the request body. Maximum 25 MB per attachment (Capsule's documented limit); the connector rejects oversized base64 before uploading. The inbound HTTP body limit is ~35 MB which leaves room for the base64 expansion of a 25 MB binary."
|
|
1831
|
+
),
|
|
1832
|
+
content: z18.string().optional().describe(
|
|
1833
|
+
"Body text for the note that will hold the attachment. Defaults to '[attachment]' if omitted."
|
|
1834
|
+
),
|
|
1835
|
+
partyId: z18.number().int().positive().optional().describe("Link the new note to a party (mutually exclusive with opportunityId / projectId)."),
|
|
1836
|
+
opportunityId: z18.number().int().positive().optional(),
|
|
1837
|
+
projectId: z18.number().int().positive().optional()
|
|
1838
|
+
});
|
|
1839
|
+
function isValidBase64(s) {
|
|
1840
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) return false;
|
|
1841
|
+
const len = s.length;
|
|
1842
|
+
if (len % 4 !== 0) return false;
|
|
1843
|
+
return true;
|
|
1844
|
+
}
|
|
1845
|
+
function decodedBase64Size(s) {
|
|
1846
|
+
const padding = s.endsWith("==") ? 2 : s.endsWith("=") ? 1 : 0;
|
|
1847
|
+
return s.length / 4 * 3 - padding;
|
|
1848
|
+
}
|
|
1849
|
+
async function uploadAttachment(input) {
|
|
1850
|
+
const linked = [input.partyId, input.opportunityId, input.projectId].filter(Boolean);
|
|
1851
|
+
if (linked.length !== 1) {
|
|
1852
|
+
throw new Error(
|
|
1853
|
+
"upload_attachment: provide exactly one of partyId, opportunityId, or projectId"
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
if (!isValidBase64(input.dataBase64)) {
|
|
1857
|
+
throw new Error(
|
|
1858
|
+
"upload_attachment: dataBase64 is not valid base64 \u2014 Node's tolerant decoder would silently produce corrupt bytes. Verify the encoding (RFC 4648, padded with '=' to a multiple of 4 chars)."
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
const decodedBytes = decodedBase64Size(input.dataBase64);
|
|
1862
|
+
if (decodedBytes > HARD_MAX_SIZE_BYTES) {
|
|
1863
|
+
throw new Error(
|
|
1864
|
+
`upload_attachment: decoded file is ${decodedBytes} bytes, exceeding the ${HARD_MAX_SIZE_BYTES} byte attachment limit. Split or shrink the file before uploading.`
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
const buffer = Buffer.from(input.dataBase64, "base64");
|
|
1868
|
+
const uploaded = await capsulePostBinary(
|
|
1869
|
+
"/attachments/upload",
|
|
1870
|
+
buffer,
|
|
1871
|
+
input.contentType,
|
|
1872
|
+
input.filename
|
|
1873
|
+
);
|
|
1874
|
+
const token = uploaded.upload.token;
|
|
1875
|
+
const entryBody = {
|
|
1876
|
+
type: "note",
|
|
1877
|
+
content: input.content ?? "[attachment]",
|
|
1878
|
+
attachments: [{ token }]
|
|
1879
|
+
};
|
|
1880
|
+
if (input.partyId) entryBody["party"] = { id: input.partyId };
|
|
1881
|
+
if (input.opportunityId) entryBody["opportunity"] = { id: input.opportunityId };
|
|
1882
|
+
if (input.projectId) entryBody["kase"] = { id: input.projectId };
|
|
1883
|
+
return capsulePost("/entries", { entry: entryBody });
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// src/tools/saved-filters.ts
|
|
1887
|
+
import { z as z19 } from "zod";
|
|
1888
|
+
var EntitySchema = z19.enum(["parties", "opportunities", "kases"]).describe(
|
|
1889
|
+
"Which entity type the filter operates over. Use 'kases' for projects (Capsule's legacy name)."
|
|
1890
|
+
);
|
|
1891
|
+
var listSavedFiltersSchema = z19.object({
|
|
1892
|
+
entity: EntitySchema
|
|
1893
|
+
});
|
|
1894
|
+
async function listSavedFilters(input) {
|
|
1895
|
+
const { data } = await capsuleGet(`/${input.entity}/filters`);
|
|
1896
|
+
return data;
|
|
1897
|
+
}
|
|
1898
|
+
var runSavedFilterSchema = z19.object({
|
|
1899
|
+
entity: EntitySchema,
|
|
1900
|
+
id: z19.number().int().positive().describe("The saved filter id (from list_saved_filters)."),
|
|
1901
|
+
embed: z19.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION),
|
|
1902
|
+
page: z19.number().int().positive().optional().default(1),
|
|
1903
|
+
perPage: z19.number().int().min(1).max(100).optional().default(25)
|
|
1904
|
+
});
|
|
1905
|
+
async function runSavedFilter(input) {
|
|
1906
|
+
const { data, nextPage } = await capsuleGet(
|
|
1907
|
+
`/${input.entity}/filters/${input.id}/results`,
|
|
1908
|
+
{ page: input.page, perPage: input.perPage, embed: input.embed }
|
|
1909
|
+
);
|
|
1910
|
+
return { ...data, nextPage };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// src/server.ts
|
|
1914
|
+
function createCapsuleMcpServer() {
|
|
1915
|
+
const readOnly = isReadOnly();
|
|
1916
|
+
const server2 = new McpServer({
|
|
1917
|
+
name: "capsulemcp",
|
|
1918
|
+
version: "1.0.0",
|
|
1919
|
+
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
1920
|
+
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
1921
|
+
icons: ICONS
|
|
1922
|
+
});
|
|
1923
|
+
registerTool(
|
|
1924
|
+
server2,
|
|
1925
|
+
"search_parties",
|
|
1926
|
+
"Free-text search or list people and organisations in Capsule CRM. Returns results in Capsule's default order (no sort parameter is supported here). For structured queries \u2014 'most recent', 'tagged X', 'added this month' \u2014 use filter_parties instead.",
|
|
1927
|
+
searchPartiesSchema,
|
|
1928
|
+
searchParties
|
|
1929
|
+
);
|
|
1930
|
+
registerTool(
|
|
1931
|
+
server2,
|
|
1932
|
+
"filter_parties",
|
|
1933
|
+
"Filter parties by structured conditions (date ranges, tags, fields). Use this \u2014 not search_parties \u2014 for questions like 'most recent client', 'parties added this week', 'parties tagged VIP'. Capsule's API does not support ad-hoc sort, but for 'most recent X' you can filter by a date field (e.g. {field: 'addedOn', operator: 'is within last', value: 30}) and pick the highest-id row from the result \u2014 Capsule IDs are monotonic, so newest id = newest record.",
|
|
1934
|
+
filterPartiesSchema,
|
|
1935
|
+
filterParties
|
|
1936
|
+
);
|
|
1937
|
+
registerTool(
|
|
1938
|
+
server2,
|
|
1939
|
+
"get_party",
|
|
1940
|
+
"Fetch a single party (person or organisation) by its numeric ID.",
|
|
1941
|
+
getPartySchema,
|
|
1942
|
+
getParty
|
|
1943
|
+
);
|
|
1944
|
+
registerTool(
|
|
1945
|
+
server2,
|
|
1946
|
+
"get_parties",
|
|
1947
|
+
"Batch-fetch up to 10 parties by ID in a single call. Use this when Claude already knows several party IDs to avoid N round trips of get_party.",
|
|
1948
|
+
getPartiesSchema,
|
|
1949
|
+
getParties
|
|
1950
|
+
);
|
|
1951
|
+
registerTool(
|
|
1952
|
+
server2,
|
|
1953
|
+
"list_party_opportunities",
|
|
1954
|
+
"List all opportunities linked to a given party.",
|
|
1955
|
+
listPartyOpportunitiesSchema,
|
|
1956
|
+
listPartyOpportunities
|
|
1957
|
+
);
|
|
1958
|
+
registerTool(
|
|
1959
|
+
server2,
|
|
1960
|
+
"list_party_projects",
|
|
1961
|
+
"List all projects (cases) linked to a given party.",
|
|
1962
|
+
listPartyProjectsSchema,
|
|
1963
|
+
listPartyProjects
|
|
1964
|
+
);
|
|
1965
|
+
registerTool(
|
|
1966
|
+
server2,
|
|
1967
|
+
"list_employees",
|
|
1968
|
+
"List the people who work at a given organisation party. Returns the parties whose `organisation` field references the given partyId. Use this to answer 'who works at X?' rather than enumerating all parties.",
|
|
1969
|
+
listEmployeesSchema,
|
|
1970
|
+
listEmployees
|
|
1971
|
+
);
|
|
1972
|
+
registerTool(
|
|
1973
|
+
server2,
|
|
1974
|
+
"list_custom_fields",
|
|
1975
|
+
"List custom field DEFINITIONS for an entity type (parties, opportunities, or projects/kases). Returns the schema \u2014 name, type, options for list-type fields, etc. \u2014 NOT the values on any specific record. To read values on a record, use get_party / get_opportunity / get_project with embed=fields.",
|
|
1976
|
+
listCustomFieldsSchema,
|
|
1977
|
+
listCustomFields
|
|
1978
|
+
);
|
|
1979
|
+
registerTool(
|
|
1980
|
+
server2,
|
|
1981
|
+
"get_custom_field",
|
|
1982
|
+
"Show a single custom field DEFINITION by id. Use list_custom_fields first to discover field ids.",
|
|
1983
|
+
getCustomFieldSchema,
|
|
1984
|
+
getCustomField
|
|
1985
|
+
);
|
|
1986
|
+
registerTool(
|
|
1987
|
+
server2,
|
|
1988
|
+
"list_deleted_parties",
|
|
1989
|
+
"Audit feature: list parties deleted on or after a given timestamp. The `since` parameter is REQUIRED (Capsule rejects the call without it). Response also includes a `restrictedParties` key \u2014 records the integration user can see were deleted but cannot read fully.",
|
|
1990
|
+
listDeletedPartiesSchema,
|
|
1991
|
+
listDeletedParties
|
|
1992
|
+
);
|
|
1993
|
+
if (!readOnly) {
|
|
1994
|
+
registerTool(
|
|
1995
|
+
server2,
|
|
1996
|
+
"create_party",
|
|
1997
|
+
"Create a new person or organisation in Capsule CRM. For type='person', firstName or lastName is required (one suffices); the `name` field is silently ignored. For type='organisation', `name` is required and firstName/lastName/title/jobTitle are silently ignored. Passing organisationId pointing at a non-organisation party (e.g. another person's id) returns 404 'organisation not found' \u2014 Capsule filters lookups by type.",
|
|
1998
|
+
createPartySchema,
|
|
1999
|
+
createParty
|
|
2000
|
+
);
|
|
2001
|
+
registerTool(
|
|
2002
|
+
server2,
|
|
2003
|
+
"update_party",
|
|
2004
|
+
"Update top-level fields on an existing party (about, firstName/lastName/name/title/jobTitle, ownerId). Only the fields you provide are changed. Child arrays (emailAddresses / phoneNumbers / addresses / websites) on this tool are APPEND-ONLY: items are merged into the existing list, not replaced. For surgical changes \u2014 replacing one email, removing one phone number, fixing the type on one address \u2014 use the dedicated atomic tools: add_party_email_address / remove_party_email_address_by_id (and the phone/address/website equivalents).",
|
|
2005
|
+
updatePartySchema,
|
|
2006
|
+
updateParty
|
|
2007
|
+
);
|
|
2008
|
+
registerTool(
|
|
2009
|
+
server2,
|
|
2010
|
+
"delete_party",
|
|
2011
|
+
"DESTRUCTIVE & IRREVERSIBLE: permanently delete a party (person or organisation). Cascades to all linked notes, tasks, opportunities, AND projects (kases). Deleting an organisation does NOT delete people linked to it via organisationId \u2014 their `organisation` field is silently cleared to null and they survive as standalone records. TRACK INSTANCES applied to cascaded opportunities/projects are NOT cleaned up either \u2014 they survive as orphan records reachable only by track id via show_track. Use remove_track on each track explicitly before deleting the parent party if orphan accumulation matters (rare in practice \u2014 orphans are unreachable from normal navigation). Requires confirm=true. Always read the party first with get_party and confirm with the user before calling. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the party was already gone (Capsule's 404 is caught internally so reconciliation loops can re-issue safely).",
|
|
2012
|
+
deletePartySchema,
|
|
2013
|
+
deleteParty
|
|
2014
|
+
);
|
|
2015
|
+
registerTool(
|
|
2016
|
+
server2,
|
|
2017
|
+
"add_party_email_address",
|
|
2018
|
+
"Append a single email address to a party. Atomic \u2014 one PUT to Capsule. Use this instead of update_party.emailAddresses when you want to add exactly one entry; the bulk array on update_party is append-only and won't replace.",
|
|
2019
|
+
addPartyEmailAddressSchema,
|
|
2020
|
+
addPartyEmailAddress
|
|
2021
|
+
);
|
|
2022
|
+
registerTool(
|
|
2023
|
+
server2,
|
|
2024
|
+
"remove_party_email_address_by_id",
|
|
2025
|
+
"Remove one email-address entry from a party by its row id. Atomic and reversible \u2014 no `confirm: true` gate (re-add with add_party_email_address). Discover the id via get_party \u2014 each entry in the emailAddresses array carries one. Use this to replace an existing entry: remove the old id, then call add_party_email_address with the new value (any associated server-side metadata on the old row is discarded along with the row). Idempotent on retry: response is `{removed: true, alreadyRemoved: false, partyId, emailAddressId, party}` on a fresh remove (the updated party shape is included) or `{removed: true, alreadyRemoved: true, partyId, emailAddressId}` if the row was already gone (Capsule's 404 is caught).",
|
|
2026
|
+
removePartyEmailAddressByIdSchema,
|
|
2027
|
+
removePartyEmailAddressById
|
|
2028
|
+
);
|
|
2029
|
+
registerTool(
|
|
2030
|
+
server2,
|
|
2031
|
+
"add_party_phone_number",
|
|
2032
|
+
"Append a single phone number to a party. Atomic \u2014 one PUT to Capsule. Use this instead of update_party.phoneNumbers for single-entry adds.",
|
|
2033
|
+
addPartyPhoneNumberSchema,
|
|
2034
|
+
addPartyPhoneNumber
|
|
2035
|
+
);
|
|
2036
|
+
registerTool(
|
|
2037
|
+
server2,
|
|
2038
|
+
"remove_party_phone_number_by_id",
|
|
2039
|
+
"Remove one phone-number entry from a party by its row id. Atomic and reversible \u2014 no `confirm: true` gate (re-add with add_party_phone_number). Discover the id via get_party. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, partyId, phoneNumberId, party}` on a fresh remove or `{removed: true, alreadyRemoved: true, partyId, phoneNumberId}` if the row was already gone.",
|
|
2040
|
+
removePartyPhoneNumberByIdSchema,
|
|
2041
|
+
removePartyPhoneNumberById
|
|
2042
|
+
);
|
|
2043
|
+
registerTool(
|
|
2044
|
+
server2,
|
|
2045
|
+
"add_party_address",
|
|
2046
|
+
"Append a single postal address to a party. Atomic \u2014 one PUT to Capsule. Use this instead of update_party.addresses for single-entry adds.",
|
|
2047
|
+
addPartyAddressSchema,
|
|
2048
|
+
addPartyAddress
|
|
2049
|
+
);
|
|
2050
|
+
registerTool(
|
|
2051
|
+
server2,
|
|
2052
|
+
"remove_party_address_by_id",
|
|
2053
|
+
"Remove one address entry from a party by its row id. Atomic and reversible \u2014 no `confirm: true` gate (re-add with add_party_address). Discover the id via get_party. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, partyId, addressId, party}` on a fresh remove or `{removed: true, alreadyRemoved: true, partyId, addressId}` if the row was already gone.",
|
|
2054
|
+
removePartyAddressByIdSchema,
|
|
2055
|
+
removePartyAddressById
|
|
2056
|
+
);
|
|
2057
|
+
registerTool(
|
|
2058
|
+
server2,
|
|
2059
|
+
"add_party_website",
|
|
2060
|
+
"Append a single website / social handle to a party. Atomic \u2014 one PUT to Capsule. Use this instead of update_party.websites for single-entry adds. The 'address' field is a URL when service='URL' or a handle (e.g. '@acmeco') for social services.",
|
|
2061
|
+
addPartyWebsiteSchema,
|
|
2062
|
+
addPartyWebsite
|
|
2063
|
+
);
|
|
2064
|
+
registerTool(
|
|
2065
|
+
server2,
|
|
2066
|
+
"remove_party_website_by_id",
|
|
2067
|
+
"Remove one website entry from a party by its row id. Atomic and reversible \u2014 no `confirm: true` gate (re-add with add_party_website). Discover the id via get_party. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, partyId, websiteId, party}` on a fresh remove or `{removed: true, alreadyRemoved: true, partyId, websiteId}` if the row was already gone.",
|
|
2068
|
+
removePartyWebsiteByIdSchema,
|
|
2069
|
+
removePartyWebsiteById
|
|
2070
|
+
);
|
|
2071
|
+
}
|
|
2072
|
+
registerTool(
|
|
2073
|
+
server2,
|
|
2074
|
+
"search_opportunities",
|
|
2075
|
+
"Free-text search or list opportunities in Capsule CRM. Returns results in Capsule's default order (no sort parameter is supported here). For structured queries \u2014 'most recent', 'won this quarter', 'in pipeline X at milestone Y' \u2014 use filter_opportunities instead.",
|
|
2076
|
+
searchOpportunitiesSchema,
|
|
2077
|
+
searchOpportunities
|
|
2078
|
+
);
|
|
2079
|
+
registerTool(
|
|
2080
|
+
server2,
|
|
2081
|
+
"filter_opportunities",
|
|
2082
|
+
"Filter opportunities by structured conditions (milestone, value, close date, tags). Use this \u2014 not search_opportunities \u2014 for questions like 'last won deal', 'opportunities closed this month', 'pipeline X at milestone Y'. Capsule's API does not support ad-hoc sort, but for 'most recent X' you can filter by a date field (e.g. {field: 'closedOn', operator: 'is within last', value: 90}) and pick the highest-id row \u2014 Capsule IDs are monotonic, so newest id = newest record.",
|
|
2083
|
+
filterOpportunitiesSchema,
|
|
2084
|
+
filterOpportunities
|
|
2085
|
+
);
|
|
2086
|
+
registerTool(
|
|
2087
|
+
server2,
|
|
2088
|
+
"get_opportunity",
|
|
2089
|
+
"Fetch a single opportunity by its numeric ID.",
|
|
2090
|
+
getOpportunitySchema,
|
|
2091
|
+
getOpportunity
|
|
2092
|
+
);
|
|
2093
|
+
registerTool(
|
|
2094
|
+
server2,
|
|
2095
|
+
"get_opportunities",
|
|
2096
|
+
"Batch-fetch up to 10 opportunities by ID in a single call.",
|
|
2097
|
+
getOpportunitiesSchema,
|
|
2098
|
+
getOpportunities
|
|
2099
|
+
);
|
|
2100
|
+
registerTool(
|
|
2101
|
+
server2,
|
|
2102
|
+
"list_deleted_opportunities",
|
|
2103
|
+
"Audit feature: list opportunities deleted on or after a given timestamp. The `since` parameter is REQUIRED. Response also includes a `restrictedOpportunities` key for records the integration user can't read fully.",
|
|
2104
|
+
listDeletedOpportunitiesSchema,
|
|
2105
|
+
listDeletedOpportunities
|
|
2106
|
+
);
|
|
2107
|
+
registerTool(
|
|
2108
|
+
server2,
|
|
2109
|
+
"list_additional_parties",
|
|
2110
|
+
"List secondary party links on an opportunity or project. The 'main' party is on the entity itself (opportunity.party); additional parties are e.g. partners, consultants, or referrers also involved in the deal. Set entity to 'opportunities' or 'kases' (Capsule's term for projects).",
|
|
2111
|
+
listAdditionalPartiesSchema,
|
|
2112
|
+
listAdditionalParties
|
|
2113
|
+
);
|
|
2114
|
+
registerTool(
|
|
2115
|
+
server2,
|
|
2116
|
+
"list_associated_projects",
|
|
2117
|
+
"List projects (cases) associated with a given opportunity. The inverse direction (project \u2192 opportunity) is on each project's `opportunity` field directly.",
|
|
2118
|
+
listAssociatedProjectsSchema,
|
|
2119
|
+
listAssociatedProjects
|
|
2120
|
+
);
|
|
2121
|
+
if (!readOnly) {
|
|
2122
|
+
registerTool(
|
|
2123
|
+
server2,
|
|
2124
|
+
"create_opportunity",
|
|
2125
|
+
"Create a new opportunity linked to a party and a pipeline milestone.",
|
|
2126
|
+
createOpportunitySchema,
|
|
2127
|
+
createOpportunity
|
|
2128
|
+
);
|
|
2129
|
+
registerTool(
|
|
2130
|
+
server2,
|
|
2131
|
+
"update_opportunity",
|
|
2132
|
+
"Update fields on an existing opportunity. Only the fields you provide are changed. Closed (Won/Lost) opportunities ARE editable \u2014 Capsule does not enforce closed-record immutability, so `value`, `description`, etc. can be changed on a Won opp without warning. If the workflow needs historical revenue numbers to be stable, enforce that caller-side.",
|
|
2133
|
+
updateOpportunitySchema,
|
|
2134
|
+
updateOpportunity
|
|
2135
|
+
);
|
|
2136
|
+
registerTool(
|
|
2137
|
+
server2,
|
|
2138
|
+
"delete_opportunity",
|
|
2139
|
+
"DESTRUCTIVE & IRREVERSIBLE: permanently delete an opportunity. Requires confirm=true. Always read the opportunity first with get_opportunity and confirm with the user before calling. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the opportunity was already gone.",
|
|
2140
|
+
deleteOpportunitySchema,
|
|
2141
|
+
deleteOpportunity
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
registerTool(
|
|
2145
|
+
server2,
|
|
2146
|
+
"list_projects",
|
|
2147
|
+
"List projects (cases) in Capsule CRM, optionally filtered by status. Returns results in Capsule's default order (no sort parameter is supported here). For structured queries \u2014 'most recent project', 'projects opened this month', 'projects tagged X' \u2014 use filter_projects instead.",
|
|
2148
|
+
listProjectsSchema,
|
|
2149
|
+
listProjects
|
|
2150
|
+
);
|
|
2151
|
+
registerTool(
|
|
2152
|
+
server2,
|
|
2153
|
+
"filter_projects",
|
|
2154
|
+
"Filter projects (cases) by structured conditions (date ranges, status, tags, owner). Use this \u2014 not list_projects \u2014 for questions like 'most recent project', 'projects opened this month'. Capsule's API does not support ad-hoc sort, but for 'most recent X' you can filter by a date field and pick the highest-id row \u2014 Capsule IDs are monotonic, so newest id = newest record.",
|
|
2155
|
+
filterProjectsSchema,
|
|
2156
|
+
filterProjects
|
|
2157
|
+
);
|
|
2158
|
+
registerTool(
|
|
2159
|
+
server2,
|
|
2160
|
+
"get_project",
|
|
2161
|
+
"Fetch a single project (case) by its numeric ID.",
|
|
2162
|
+
getProjectSchema,
|
|
2163
|
+
getProject
|
|
2164
|
+
);
|
|
2165
|
+
registerTool(
|
|
2166
|
+
server2,
|
|
2167
|
+
"get_projects",
|
|
2168
|
+
"Batch-fetch up to 10 projects (cases) by ID in a single call.",
|
|
2169
|
+
getProjectsSchema,
|
|
2170
|
+
getProjects
|
|
2171
|
+
);
|
|
2172
|
+
registerTool(
|
|
2173
|
+
server2,
|
|
2174
|
+
"list_deleted_projects",
|
|
2175
|
+
"Audit feature: list projects deleted on or after a given timestamp. The `since` parameter is REQUIRED. Response also includes a `restrictedKases` key for records the integration user can't read fully.",
|
|
2176
|
+
listDeletedProjectsSchema,
|
|
2177
|
+
listDeletedProjects
|
|
2178
|
+
);
|
|
2179
|
+
if (!readOnly) {
|
|
2180
|
+
registerTool(
|
|
2181
|
+
server2,
|
|
2182
|
+
"create_project",
|
|
2183
|
+
"Create a new project (case) in Capsule CRM linked to a party.",
|
|
2184
|
+
createProjectSchema,
|
|
2185
|
+
createProject
|
|
2186
|
+
);
|
|
2187
|
+
registerTool(
|
|
2188
|
+
server2,
|
|
2189
|
+
"update_project",
|
|
2190
|
+
"Update fields on an existing project. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable \u2014 Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning.",
|
|
2191
|
+
updateProjectSchema,
|
|
2192
|
+
updateProject
|
|
2193
|
+
);
|
|
2194
|
+
registerTool(
|
|
2195
|
+
server2,
|
|
2196
|
+
"delete_project",
|
|
2197
|
+
"DESTRUCTIVE & IRREVERSIBLE: permanently delete a project (case). Prefer update_project with status='CLOSED' to close a project while preserving history. Requires confirm=true. Always read the project first with get_project and confirm with the user before calling. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the project was already gone.",
|
|
2198
|
+
deleteProjectSchema,
|
|
2199
|
+
deleteProject
|
|
2200
|
+
);
|
|
2201
|
+
registerTool(
|
|
2202
|
+
server2,
|
|
2203
|
+
"add_additional_party",
|
|
2204
|
+
"Link an existing party as an additional (secondary) party on an opportunity or project. The 'main' party is set via update_opportunity / update_project; this adds *additional* parties beyond the main one. Idempotent \u2014 re-adding a linked party is harmless. Response: `{linked: true, alreadyLinked: false}` on a fresh link, `{linked: true, alreadyLinked: true}` if the party was already linked (Capsule's 422 'already a contact' / 'already related' is caught internally and converted).",
|
|
2205
|
+
addAdditionalPartySchema,
|
|
2206
|
+
addAdditionalParty
|
|
2207
|
+
);
|
|
2208
|
+
registerTool(
|
|
2209
|
+
server2,
|
|
2210
|
+
"remove_additional_party",
|
|
2211
|
+
"Remove an additional-party link between an opportunity/project and a party. The party itself is NOT deleted. Requires confirm=true. Reversible by re-adding via add_additional_party. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, entity, entityId, partyId}` on a fresh remove or `{removed: true, alreadyRemoved: true, ...}` if the link was already gone (Capsule's 404 is caught and converted).",
|
|
2212
|
+
removeAdditionalPartySchema,
|
|
2213
|
+
removeAdditionalParty
|
|
2214
|
+
);
|
|
2215
|
+
registerTool(
|
|
2216
|
+
server2,
|
|
2217
|
+
"apply_track",
|
|
2218
|
+
"Apply a track definition to an opportunity or project. Creates a track instance and auto-creates tasks per the track's task definitions; tasks' `dueOn` is computed from `startDate` (defaults to today) plus each task's `daysAfter` offset. Use list_track_definitions to discover available templates. NOT IDEMPOTENT \u2014 applying the same trackDefinitionId twice creates two independent track instances and two sets of auto-tasks (no de-duplication). If you want to apply only once, call list_entity_tracks first and check for an existing instance with the same trackDefinition.id (but mind that list_entity_tracks can include auto-applied tracks from board stage rules, not just manual applies).",
|
|
2219
|
+
applyTrackSchema,
|
|
2220
|
+
applyTrack
|
|
2221
|
+
);
|
|
2222
|
+
registerTool(
|
|
2223
|
+
server2,
|
|
2224
|
+
"update_track",
|
|
2225
|
+
"Update a track instance. Capsule's PUT semantics are partial \u2014 provide only the fields you want to change in `fields`. Common: { complete: true } to mark a track completed.",
|
|
2226
|
+
updateTrackSchema,
|
|
2227
|
+
updateTrack
|
|
2228
|
+
);
|
|
2229
|
+
registerTool(
|
|
2230
|
+
server2,
|
|
2231
|
+
"remove_track",
|
|
2232
|
+
"Remove a track instance from its entity. Capsule also deletes the auto-tasks the track created when it was applied; copy any task details you need before removing the track. Requires confirm=true. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, trackId}` on a fresh remove or `{removed: true, alreadyRemoved: true, trackId}` if the track was already gone.",
|
|
2233
|
+
removeTrackSchema,
|
|
2234
|
+
removeTrack
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
registerTool(
|
|
2238
|
+
server2,
|
|
2239
|
+
"list_tasks",
|
|
2240
|
+
"List tasks in Capsule CRM. Defaults to OPEN tasks; pass status to broaden. Optionally filter to a specific owner via ownerId. Capsule does not expose a due-date filter on this endpoint \u2014 for that use filter_* tools elsewhere or iterate.",
|
|
2241
|
+
listTasksSchema,
|
|
2242
|
+
listTasks
|
|
2243
|
+
);
|
|
2244
|
+
registerTool(
|
|
2245
|
+
server2,
|
|
2246
|
+
"get_task",
|
|
2247
|
+
"Fetch a single task by its numeric ID.",
|
|
2248
|
+
getTaskSchema,
|
|
2249
|
+
getTask
|
|
2250
|
+
);
|
|
2251
|
+
registerTool(
|
|
2252
|
+
server2,
|
|
2253
|
+
"get_tasks",
|
|
2254
|
+
"Batch-fetch up to 10 tasks by ID in a single call.",
|
|
2255
|
+
getTasksSchema,
|
|
2256
|
+
getTasks
|
|
2257
|
+
);
|
|
2258
|
+
if (!readOnly) {
|
|
2259
|
+
registerTool(
|
|
2260
|
+
server2,
|
|
2261
|
+
"create_task",
|
|
2262
|
+
"Create a new task, optionally linked to a party, opportunity, or project. Pass at most ONE of partyId / opportunityId / projectId \u2014 the connector rejects multi-target inputs before the HTTP call. Omitting all three is also valid: Capsule creates the task as a STANDALONE task (no parent link), useful for personal reminders or workflow tasks that aren't tied to a specific CRM record.",
|
|
2263
|
+
createTaskSchema,
|
|
2264
|
+
createTask
|
|
2265
|
+
);
|
|
2266
|
+
registerTool(
|
|
2267
|
+
server2,
|
|
2268
|
+
"update_task",
|
|
2269
|
+
"Update fields on an existing task. Only the fields you provide are changed. To mark a task done prefer complete_task.",
|
|
2270
|
+
updateTaskSchema,
|
|
2271
|
+
updateTask
|
|
2272
|
+
);
|
|
2273
|
+
registerTool(
|
|
2274
|
+
server2,
|
|
2275
|
+
"complete_task",
|
|
2276
|
+
"Mark a task as done / completed / finished. Sets status=COMPLETED on the task, populating completedBy and completedAt while preserving the task in history (unlike delete_task which removes it permanently). Use this whenever a user says 'mark done', 'complete', 'finish', or similar \u2014 equivalent to update_task with status:COMPLETED but more discoverable.",
|
|
2277
|
+
completeTaskSchema,
|
|
2278
|
+
completeTask
|
|
2279
|
+
);
|
|
2280
|
+
registerTool(
|
|
2281
|
+
server2,
|
|
2282
|
+
"delete_task",
|
|
2283
|
+
"DESTRUCTIVE & IRREVERSIBLE: permanently delete a task. Prefer complete_task to mark a task done while keeping it in history. Requires confirm=true. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the task was already gone.",
|
|
2284
|
+
deleteTaskSchema,
|
|
2285
|
+
deleteTask
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
registerTool(
|
|
2289
|
+
server2,
|
|
2290
|
+
"list_party_entries",
|
|
2291
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Use this to read the conversation history with a contact or organisation.",
|
|
2292
|
+
listPartyEntriesSchema,
|
|
2293
|
+
listPartyEntries
|
|
2294
|
+
);
|
|
2295
|
+
registerTool(
|
|
2296
|
+
server2,
|
|
2297
|
+
"list_opportunity_entries",
|
|
2298
|
+
"List timeline entries (notes, captured emails, completed-task records) for an opportunity.",
|
|
2299
|
+
listOpportunityEntriesSchema,
|
|
2300
|
+
listOpportunityEntries
|
|
2301
|
+
);
|
|
2302
|
+
registerTool(
|
|
2303
|
+
server2,
|
|
2304
|
+
"list_project_entries",
|
|
2305
|
+
"List timeline entries (notes, captured emails, completed-task records) for a project (case).",
|
|
2306
|
+
listProjectEntriesSchema,
|
|
2307
|
+
listProjectEntries
|
|
2308
|
+
);
|
|
2309
|
+
registerTool(
|
|
2310
|
+
server2,
|
|
2311
|
+
"get_entry",
|
|
2312
|
+
"Fetch a single timeline entry by its numeric ID. Returns full content (note body, email subject + body, etc.).",
|
|
2313
|
+
getEntrySchema,
|
|
2314
|
+
getEntry
|
|
2315
|
+
);
|
|
2316
|
+
registerTool(
|
|
2317
|
+
server2,
|
|
2318
|
+
"list_entries",
|
|
2319
|
+
"Global timeline feed: every note, captured email, and completed-task record across the whole Capsule account, paginated. Default order is most-recent-first. Use this for 'what activity happened today/this week across the company?' rather than iterating list_party_entries / list_opportunity_entries / list_project_entries.",
|
|
2320
|
+
listEntriesSchema,
|
|
2321
|
+
listEntries
|
|
2322
|
+
);
|
|
2323
|
+
server2.tool(
|
|
2324
|
+
"get_attachment",
|
|
2325
|
+
"Download an attachment by id. Returns image content for image/* types (Claude can describe it natively); decoded text for text/* and application/json (small files); JSON metadata + base64 payload for other binary types (PDF, Office docs, etc.). Files exceeding maxSizeBytes (default 5MB) return metadata only with a `truncated: true` flag.",
|
|
2326
|
+
getAttachmentSchema.shape,
|
|
2327
|
+
async (input) => {
|
|
2328
|
+
const result = await getAttachment(input);
|
|
2329
|
+
if (result.truncated) {
|
|
2330
|
+
return {
|
|
2331
|
+
content: [
|
|
2332
|
+
{
|
|
2333
|
+
type: "text",
|
|
2334
|
+
text: JSON.stringify(
|
|
2335
|
+
{
|
|
2336
|
+
id: input.id,
|
|
2337
|
+
contentType: result.contentType,
|
|
2338
|
+
sizeBytes: result.sizeBytes,
|
|
2339
|
+
truncated: true,
|
|
2340
|
+
message: `File exceeds the size cap (${input.maxSizeBytes ?? "default"} bytes). Increase maxSizeBytes if you need the bytes; max is 25MB.`
|
|
2341
|
+
},
|
|
2342
|
+
null,
|
|
2343
|
+
2
|
|
2344
|
+
)
|
|
2345
|
+
}
|
|
2346
|
+
]
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
const baseType = result.contentType.split(";")[0].trim().toLowerCase();
|
|
2350
|
+
if (baseType.startsWith("image/")) {
|
|
2351
|
+
return {
|
|
2352
|
+
content: [
|
|
2353
|
+
{
|
|
2354
|
+
type: "image",
|
|
2355
|
+
data: result.buffer.toString("base64"),
|
|
2356
|
+
mimeType: result.contentType
|
|
2357
|
+
}
|
|
2358
|
+
]
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
const isText = baseType.startsWith("text/") || baseType === "application/json" || baseType === "application/xml";
|
|
2362
|
+
if (isText) {
|
|
2363
|
+
return {
|
|
2364
|
+
content: [
|
|
2365
|
+
{
|
|
2366
|
+
type: "text",
|
|
2367
|
+
text: JSON.stringify(
|
|
2368
|
+
{
|
|
2369
|
+
id: input.id,
|
|
2370
|
+
contentType: result.contentType,
|
|
2371
|
+
sizeBytes: result.sizeBytes
|
|
2372
|
+
},
|
|
2373
|
+
null,
|
|
2374
|
+
2
|
|
2375
|
+
)
|
|
2376
|
+
},
|
|
2377
|
+
{ type: "text", text: result.buffer.toString("utf8") }
|
|
2378
|
+
]
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
return {
|
|
2382
|
+
content: [
|
|
2383
|
+
{
|
|
2384
|
+
type: "text",
|
|
2385
|
+
text: JSON.stringify(
|
|
2386
|
+
{
|
|
2387
|
+
id: input.id,
|
|
2388
|
+
contentType: result.contentType,
|
|
2389
|
+
sizeBytes: result.sizeBytes,
|
|
2390
|
+
base64: result.buffer.toString("base64")
|
|
2391
|
+
},
|
|
2392
|
+
null,
|
|
2393
|
+
2
|
|
2394
|
+
)
|
|
2395
|
+
}
|
|
2396
|
+
]
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
);
|
|
2400
|
+
if (!readOnly) {
|
|
2401
|
+
registerTool(
|
|
2402
|
+
server2,
|
|
2403
|
+
"add_note",
|
|
2404
|
+
"Add a note to a party, opportunity, or project. Provide exactly one of partyId, opportunityId, or projectId. The note is always attributed to the API-token owner \u2014 there is no override for the author (a `creatorId` parameter would enable audit-attribution spoofing on shared-connector deployments, so it is intentionally not exposed). Optional `entryAt` lets you backdate the note's authored-at timestamp for legitimate historical-import workflows.",
|
|
2405
|
+
addNoteSchema,
|
|
2406
|
+
addNote
|
|
2407
|
+
);
|
|
2408
|
+
registerTool(
|
|
2409
|
+
server2,
|
|
2410
|
+
"update_entry",
|
|
2411
|
+
"Edit an existing timeline entry \u2014 typically a note. Provide the entry id plus the fields you want to change (content, subject). Only the fields you supply are modified; other fields keep their current values. Cannot change the entry's type. Use this to correct or extend a note added previously.",
|
|
2412
|
+
updateEntrySchema,
|
|
2413
|
+
updateEntry
|
|
2414
|
+
);
|
|
2415
|
+
registerTool(
|
|
2416
|
+
server2,
|
|
2417
|
+
"upload_attachment",
|
|
2418
|
+
"Upload a file as a new note attachment, linked to a party, opportunity, or project. Provide the file as base64-encoded `dataBase64` along with `filename` and `contentType` (MIME). Also provide exactly one of partyId / opportunityId / projectId to anchor the note. Optionally pass `content` to set the note body (defaults to '[attachment]'). Two-step orchestration server-side: bytes upload \u2192 token \u2192 note creation. Adding to an existing entry is not supported.",
|
|
2419
|
+
uploadAttachmentSchema,
|
|
2420
|
+
uploadAttachment
|
|
2421
|
+
);
|
|
2422
|
+
registerTool(
|
|
2423
|
+
server2,
|
|
2424
|
+
"delete_entry",
|
|
2425
|
+
"DESTRUCTIVE & IRREVERSIBLE: permanently delete a note (or other entry) by its ID. Requires confirm=true. Idempotent on retry: response is `{deleted: true, alreadyDeleted: false, id}` on a fresh delete or `{deleted: true, alreadyDeleted: true, id}` if the entry was already gone.",
|
|
2426
|
+
deleteEntrySchema,
|
|
2427
|
+
deleteEntry
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
registerTool(
|
|
2431
|
+
server2,
|
|
2432
|
+
"list_pipelines",
|
|
2433
|
+
"List all sales pipelines defined in Capsule CRM.",
|
|
2434
|
+
listPipelinesSchema,
|
|
2435
|
+
listPipelines
|
|
2436
|
+
);
|
|
2437
|
+
registerTool(
|
|
2438
|
+
server2,
|
|
2439
|
+
"list_milestones",
|
|
2440
|
+
"List all milestones (stages) within a specific opportunity pipeline.",
|
|
2441
|
+
listMilestonesSchema,
|
|
2442
|
+
listMilestones
|
|
2443
|
+
);
|
|
2444
|
+
registerTool(
|
|
2445
|
+
server2,
|
|
2446
|
+
"list_boards",
|
|
2447
|
+
"List all project (kase) boards defined in Capsule. A board is a grouping of stages that projects flow through \u2014 the project equivalent of an opportunity pipeline.",
|
|
2448
|
+
listBoardsSchema,
|
|
2449
|
+
listBoards
|
|
2450
|
+
);
|
|
2451
|
+
registerTool(
|
|
2452
|
+
server2,
|
|
2453
|
+
"list_stages",
|
|
2454
|
+
"List project stages. Without arguments returns every stage across every board (each carries a .board reference). Pass boardId to scope to one specific board.",
|
|
2455
|
+
listStagesSchema,
|
|
2456
|
+
listStages
|
|
2457
|
+
);
|
|
2458
|
+
registerTool(
|
|
2459
|
+
server2,
|
|
2460
|
+
"list_teams",
|
|
2461
|
+
"List all teams configured in the Capsule account. Useful as input for filter_* queries that scope by team, and for reporting. LIMITATION: returns team identity only (id, name, description, timestamps). Capsule's v2 API does not expose team\u2194user membership through any endpoint \u2014 `GET /teams/{id}/users` 404s, `embed=users` is silently ignored, and `GET /users/{id}` doesn't include a `teams` field. To determine whether a given user belongs to a given team, either check Capsule's web UI Team Membership page, or probe via `update_project { ownerId: U, teamId: T }` and read the response \u2014 422 'owner is not a member of the team' means U \u2209 T.",
|
|
2462
|
+
listTeamsSchema,
|
|
2463
|
+
listTeams
|
|
2464
|
+
);
|
|
2465
|
+
registerTool(
|
|
2466
|
+
server2,
|
|
2467
|
+
"list_lostreasons",
|
|
2468
|
+
"List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
|
|
2469
|
+
listLostReasonsSchema,
|
|
2470
|
+
listLostReasons
|
|
2471
|
+
);
|
|
2472
|
+
registerTool(
|
|
2473
|
+
server2,
|
|
2474
|
+
"list_activitytypes",
|
|
2475
|
+
"List all configured activity types (e.g. Call, Meeting, Email). These are the categories used when logging timeline entries.",
|
|
2476
|
+
listActivityTypesSchema,
|
|
2477
|
+
listActivityTypes
|
|
2478
|
+
);
|
|
2479
|
+
registerTool(
|
|
2480
|
+
server2,
|
|
2481
|
+
"list_categories",
|
|
2482
|
+
"List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Used to label and filter timeline entries and tasks.",
|
|
2483
|
+
listCategoriesSchema,
|
|
2484
|
+
listCategories
|
|
2485
|
+
);
|
|
2486
|
+
registerTool(
|
|
2487
|
+
server2,
|
|
2488
|
+
"list_track_definitions",
|
|
2489
|
+
"List workflow track definitions: reusable templates that auto-create tasks at configured intervals when applied to an opportunity or project. Each track includes nested taskDefinitions specifying what to create and when. Use this to understand what automations exist.",
|
|
2490
|
+
listTrackDefinitionsSchema,
|
|
2491
|
+
listTrackDefinitions
|
|
2492
|
+
);
|
|
2493
|
+
registerTool(
|
|
2494
|
+
server2,
|
|
2495
|
+
"list_entity_tracks",
|
|
2496
|
+
"List track INSTANCES on a specific record \u2014 i.e., which tracks have been applied to this opportunity / project / party. Distinct from list_track_definitions, which lists the templates. NOTE: some boards have stage-triggered automation that auto-applies tracks when an entity enters specific stages \u2014 tracks returned here may include BOTH manually-applied tracks (via apply_track) and auto-applied tracks from Capsule board rules. To distinguish, compare each track's `trackDefinition.id` against your application's apply_track call history.",
|
|
2497
|
+
listEntityTracksSchema,
|
|
2498
|
+
listEntityTracks
|
|
2499
|
+
);
|
|
2500
|
+
registerTool(
|
|
2501
|
+
server2,
|
|
2502
|
+
"show_track",
|
|
2503
|
+
"Fetch a single track instance by id. Returns the minimal Capsule projection: id, description, trackDateOn, direction, and the array of tasks attached to the track. Capsule's GET /tracks/{id} does NOT include a trackDefinition link, an entity reference, or a completion field \u2014 to find the entity a track is applied to, use list_entity_tracks (which lists track instances by their parent entity); to check completion, the track-tasks' own statuses are the proxy.",
|
|
2504
|
+
showTrackSchema,
|
|
2505
|
+
showTrack
|
|
2506
|
+
);
|
|
2507
|
+
registerTool(
|
|
2508
|
+
server2,
|
|
2509
|
+
"list_goals",
|
|
2510
|
+
"List sales / activity goals configured in the account (per-user or per-team revenue or activity targets). Returns an empty list for accounts that don't use the Goals feature.",
|
|
2511
|
+
listGoalsSchema,
|
|
2512
|
+
listGoals
|
|
2513
|
+
);
|
|
2514
|
+
registerTool(
|
|
2515
|
+
server2,
|
|
2516
|
+
"get_site",
|
|
2517
|
+
"Return the Capsule account this connector is currently authenticated against (subdomain, display name, URL). Diagnostic for 'which Capsule account is this?'. For the PAT owner's user identity, use get_current_user.",
|
|
2518
|
+
getSiteSchema,
|
|
2519
|
+
getSite
|
|
2520
|
+
);
|
|
2521
|
+
registerTool(
|
|
2522
|
+
server2,
|
|
2523
|
+
"list_saved_filters",
|
|
2524
|
+
"List all filters that users have saved in Capsule's web UI for an entity type. Saved filters are reusable \u2014 they bundle conditions, columns, and (importantly) sort. Use this to discover what queries are already configured before building a one-off filter_* call.",
|
|
2525
|
+
listSavedFiltersSchema,
|
|
2526
|
+
listSavedFilters
|
|
2527
|
+
);
|
|
2528
|
+
registerTool(
|
|
2529
|
+
server2,
|
|
2530
|
+
"run_saved_filter",
|
|
2531
|
+
"Run a saved filter by id and return its results, paginated. Unlike filter_parties / filter_opportunities / filter_projects (which use the ad-hoc filter endpoint and CANNOT sort), saved filters DO support sort \u2014 the orderBy is configured in Capsule's web UI when the filter is created. So 'most recent X by Y' questions are answerable in one call IF a saved filter exists; use list_saved_filters first to find one.",
|
|
2532
|
+
runSavedFilterSchema,
|
|
2533
|
+
runSavedFilter
|
|
2534
|
+
);
|
|
2535
|
+
registerTool(
|
|
2536
|
+
server2,
|
|
2537
|
+
"list_tags",
|
|
2538
|
+
"List all tags available for a given entity type (parties, opportunities, or kases).",
|
|
2539
|
+
listTagsSchema,
|
|
2540
|
+
listTags
|
|
2541
|
+
);
|
|
2542
|
+
if (!readOnly) {
|
|
2543
|
+
registerTool(
|
|
2544
|
+
server2,
|
|
2545
|
+
"add_tag",
|
|
2546
|
+
"Attach a tag to a party, opportunity, or project (kase) by NAME. Capsule resolves to an existing tag in the tenant or creates a fresh one with this name. Matching is case-insensitive \u2014 'VIP' and 'vip' attach the same tag, preserving the canonical casing from whichever variant was created first. To avoid creating a genuinely-distinct near-duplicate (e.g. 'VIP' vs 'V.I.P.'), call list_tags first and reuse the exact name. Idempotent \u2014 re-attaching an already-attached tag is harmless. To DETACH a tag, use remove_tag_by_id with the tag's id (read via get_party/get_opportunity/get_project with embed='tags').",
|
|
2547
|
+
addTagSchema,
|
|
2548
|
+
addTag
|
|
2549
|
+
);
|
|
2550
|
+
registerTool(
|
|
2551
|
+
server2,
|
|
2552
|
+
"remove_tag_by_id",
|
|
2553
|
+
"Detach a tag from a party, opportunity, or project (kase). Atomic \u2014 one PUT to Capsule. Reversible \u2014 no `confirm: true` gate (re-attach with add_tag using the same tag name). The `tagId` parameter is the tag's id, readable via get_party/get_opportunity/get_project with embed='tags' (list_tags returns the same ids and also works, but reading via embed first confirms the tag is actually attached to this entity). The tag definition itself remains in the tenant for other entities that still share it. Idempotent on retry: response is `{removed: true, alreadyRemoved: false, entity, entityId, tagId, ...<updated entity>}` on a fresh detach or `{removed: true, alreadyRemoved: true, entity, entityId, tagId}` if the tag was already detached (Capsule's 422 'tag not found to delete' is caught and converted).",
|
|
2554
|
+
removeTagByIdSchema,
|
|
2555
|
+
removeTagById
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
registerTool(
|
|
2559
|
+
server2,
|
|
2560
|
+
"list_users",
|
|
2561
|
+
"List all users in the Capsule account.",
|
|
2562
|
+
listUsersSchema,
|
|
2563
|
+
listUsers
|
|
2564
|
+
);
|
|
2565
|
+
registerTool(
|
|
2566
|
+
server2,
|
|
2567
|
+
"get_current_user",
|
|
2568
|
+
"Show the user owning the API token this connector is using. Useful for audit ('under whose Capsule identity is the connector running?') and for confirming a token rotation moved ownership to the expected account. Wraps Capsule's GET /users/current \u2014 note the endpoint is /users/current, not /users/me.",
|
|
2569
|
+
getCurrentUserSchema,
|
|
2570
|
+
getCurrentUser
|
|
2571
|
+
);
|
|
2572
|
+
return server2;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// src/index.ts
|
|
2576
|
+
if (!process.env["CAPSULE_API_TOKEN"]) {
|
|
2577
|
+
console.error(
|
|
2578
|
+
"[capsulemcp] CAPSULE_API_TOKEN environment variable is not set. Generate a Personal Access Token via My Preferences \u2192 API Authentication Tokens in Capsule."
|
|
2579
|
+
);
|
|
2580
|
+
process.exit(1);
|
|
2581
|
+
}
|
|
2582
|
+
var server = createCapsuleMcpServer();
|
|
2583
|
+
var transport = new StdioServerTransport();
|
|
2584
|
+
if (isReadOnly()) {
|
|
2585
|
+
console.error("[capsulemcp] read-only mode: write/delete tools are not registered");
|
|
2586
|
+
}
|
|
2587
|
+
try {
|
|
2588
|
+
await server.connect(transport);
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2591
|
+
console.error(`[capsulemcp] Failed to start: ${message}`);
|
|
2592
|
+
process.exit(1);
|
|
2593
|
+
}
|