capsulemcp 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/http.js +114 -78
- package/dist/index.js +114 -78
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server for [Capsule
|
|
|
17
17
|
| Example questions to ask once the connector is running | [EXAMPLES.md](EXAMPLES.md) |
|
|
18
18
|
| To use it locally with Claude Desktop or Claude Code | [INSTALL.md](INSTALL.md) |
|
|
19
19
|
| To deploy it once and have your whole team use it via Claude.ai | [DEPLOY.md](DEPLOY.md) |
|
|
20
|
+
| To wire it into n8n workflows | [INTEGRATIONS-n8n.md](INTEGRATIONS-n8n.md) |
|
|
20
21
|
| To contribute, debug, add a tool, or cut a release | [HOWTO.md](HOWTO.md) (procedures) · [CONTRIBUTING.md](CONTRIBUTING.md) (style & PR checks) |
|
|
21
22
|
| To understand what's intentionally not implemented (and why) | [DESIGN.md](DESIGN.md) |
|
|
22
23
|
| To see what performance work has been done (and what's next) | [OPTIMIZATIONS.md](OPTIMIZATIONS.md) |
|
|
@@ -47,7 +48,7 @@ For most individual users the install is a single JSON snippet pasted into Claud
|
|
|
47
48
|
|
|
48
49
|
3. Restart Claude Desktop. The Capsule tools appear in the tool picker.
|
|
49
50
|
|
|
50
|
-
That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.6.
|
|
51
|
+
That's it. The first launch fetches the package from npm (a few seconds); subsequent launches are instant from the npx cache. To pin a specific version, use `"capsulemcp@1.6.1"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.1"` — same arguments, just installs from a git clone rather than the npm registry. See [INSTALL.md](INSTALL.md) for the Claude Code path, manual install, and troubleshooting.
|
|
51
52
|
|
|
52
53
|
## Tools
|
|
53
54
|
|
package/dist/http.js
CHANGED
|
@@ -304,11 +304,22 @@ async function doFetch(url, options) {
|
|
|
304
304
|
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
305
305
|
);
|
|
306
306
|
}
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
return { ...retried, startedAt, method, url, retriedAfter429: true };
|
|
308
|
+
}
|
|
309
|
+
return { ...first, startedAt, method, url, retriedAfter429: false };
|
|
310
|
+
}
|
|
311
|
+
async function consumeBody(start, body) {
|
|
312
|
+
try {
|
|
313
|
+
return await body();
|
|
314
|
+
} finally {
|
|
315
|
+
emitCapsuleRequest(
|
|
316
|
+
start.method,
|
|
317
|
+
start.url,
|
|
318
|
+
start.res,
|
|
319
|
+
Date.now() - start.startedAt,
|
|
320
|
+
start.retriedAfter429
|
|
321
|
+
);
|
|
309
322
|
}
|
|
310
|
-
emitCapsuleRequest(method, url, first.res, Date.now() - startedAt, false);
|
|
311
|
-
return first;
|
|
312
323
|
}
|
|
313
324
|
function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
314
325
|
let path = "";
|
|
@@ -358,13 +369,15 @@ function buildUrl(path, params) {
|
|
|
358
369
|
async function capsuleGet(path, params) {
|
|
359
370
|
const token = getToken();
|
|
360
371
|
const url = buildUrl(path, params);
|
|
361
|
-
const
|
|
372
|
+
const start = await doFetch(url, { headers: baseHeaders(token) });
|
|
362
373
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
374
|
+
return await consumeBody(start, async () => {
|
|
375
|
+
const data = await handleResponse(start.res);
|
|
376
|
+
const nextPage = parseNextPage(start.res.headers.get("Link"));
|
|
377
|
+
return { data, nextPage };
|
|
378
|
+
});
|
|
366
379
|
} finally {
|
|
367
|
-
cleanup();
|
|
380
|
+
start.cleanup();
|
|
368
381
|
}
|
|
369
382
|
}
|
|
370
383
|
async function capsuleGetCached(path, params) {
|
|
@@ -399,123 +412,130 @@ async function capsulePost(path, body) {
|
|
|
399
412
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
400
413
|
const token = getToken();
|
|
401
414
|
const url = buildUrl(path);
|
|
402
|
-
const
|
|
415
|
+
const start = await doFetch(url, {
|
|
403
416
|
method: "POST",
|
|
404
417
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
405
418
|
body: JSON.stringify(body)
|
|
406
419
|
});
|
|
407
420
|
try {
|
|
408
|
-
return await handleResponse(res);
|
|
421
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
409
422
|
} finally {
|
|
410
|
-
cleanup();
|
|
423
|
+
start.cleanup();
|
|
411
424
|
}
|
|
412
425
|
}
|
|
413
426
|
async function capsulePostNoContent(path) {
|
|
414
427
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
415
428
|
const token = getToken();
|
|
416
429
|
const url = buildUrl(path);
|
|
417
|
-
const
|
|
430
|
+
const start = await doFetch(url, {
|
|
418
431
|
method: "POST",
|
|
419
432
|
headers: baseHeaders(token)
|
|
420
433
|
});
|
|
421
434
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
435
|
+
await consumeBody(start, async () => {
|
|
436
|
+
if (start.res.status === 204) return;
|
|
437
|
+
await throwForStatus(start.res);
|
|
438
|
+
await mapAbort(start.res.text());
|
|
439
|
+
});
|
|
425
440
|
} finally {
|
|
426
|
-
cleanup();
|
|
441
|
+
start.cleanup();
|
|
427
442
|
}
|
|
428
443
|
}
|
|
429
444
|
async function capsuleSearch(path, body, params) {
|
|
430
445
|
const token = getToken();
|
|
431
446
|
const url = buildUrl(path, params);
|
|
432
|
-
const
|
|
447
|
+
const start = await doFetch(url, {
|
|
433
448
|
method: "POST",
|
|
434
449
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
435
450
|
body: JSON.stringify(body)
|
|
436
451
|
});
|
|
437
452
|
try {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
453
|
+
return await consumeBody(start, async () => {
|
|
454
|
+
const data = await handleResponse(start.res);
|
|
455
|
+
const nextPage = parseNextPage(start.res.headers.get("Link"));
|
|
456
|
+
return { data, nextPage };
|
|
457
|
+
});
|
|
441
458
|
} finally {
|
|
442
|
-
cleanup();
|
|
459
|
+
start.cleanup();
|
|
443
460
|
}
|
|
444
461
|
}
|
|
445
462
|
async function capsulePut(path, body) {
|
|
446
463
|
if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
|
|
447
464
|
const token = getToken();
|
|
448
465
|
const url = buildUrl(path);
|
|
449
|
-
const
|
|
466
|
+
const start = await doFetch(url, {
|
|
450
467
|
method: "PUT",
|
|
451
468
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
452
469
|
body: JSON.stringify(body)
|
|
453
470
|
});
|
|
454
471
|
try {
|
|
455
|
-
return await handleResponse(res);
|
|
472
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
456
473
|
} finally {
|
|
457
|
-
cleanup();
|
|
474
|
+
start.cleanup();
|
|
458
475
|
}
|
|
459
476
|
}
|
|
460
477
|
async function capsuleGetBinary(path, maxBytes) {
|
|
461
478
|
const token = getToken();
|
|
462
479
|
const url = buildUrl(path);
|
|
463
|
-
const
|
|
480
|
+
const start = await doFetch(url, { headers: baseHeaders(token) });
|
|
464
481
|
try {
|
|
465
|
-
await
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
buffer: Buffer.alloc(0),
|
|
475
|
-
truncated: true,
|
|
476
|
-
sizeBytes: declaredBytes
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
if (maxBytes !== void 0 && res.body) {
|
|
480
|
-
const reader = res.body.getReader();
|
|
481
|
-
const chunks = [];
|
|
482
|
-
let total = 0;
|
|
483
|
-
let truncated = false;
|
|
484
|
-
while (true) {
|
|
485
|
-
const { done, value } = await mapAbort(reader.read());
|
|
486
|
-
if (done) break;
|
|
487
|
-
total += value.byteLength;
|
|
488
|
-
if (total > maxBytes) {
|
|
489
|
-
truncated = true;
|
|
490
|
-
await reader.cancel().catch(() => {
|
|
491
|
-
});
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
chunks.push(value);
|
|
495
|
-
}
|
|
496
|
-
if (truncated) {
|
|
482
|
+
return await consumeBody(start, async () => {
|
|
483
|
+
const res = start.res;
|
|
484
|
+
await throwForStatus(res);
|
|
485
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
486
|
+
const declared = res.headers.get("Content-Length");
|
|
487
|
+
const declaredBytes = declared ? Number(declared) : NaN;
|
|
488
|
+
if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
489
|
+
if (res.body) await res.body.cancel().catch(() => {
|
|
490
|
+
});
|
|
497
491
|
return {
|
|
498
492
|
contentType,
|
|
499
493
|
buffer: Buffer.alloc(0),
|
|
500
494
|
truncated: true,
|
|
501
|
-
sizeBytes:
|
|
495
|
+
sizeBytes: declaredBytes
|
|
502
496
|
};
|
|
503
497
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
498
|
+
if (maxBytes !== void 0 && res.body) {
|
|
499
|
+
const reader = res.body.getReader();
|
|
500
|
+
const chunks = [];
|
|
501
|
+
let total = 0;
|
|
502
|
+
let truncated = false;
|
|
503
|
+
while (true) {
|
|
504
|
+
const { done, value } = await mapAbort(reader.read());
|
|
505
|
+
if (done) break;
|
|
506
|
+
total += value.byteLength;
|
|
507
|
+
if (total > maxBytes) {
|
|
508
|
+
truncated = true;
|
|
509
|
+
await reader.cancel().catch(() => {
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
chunks.push(value);
|
|
514
|
+
}
|
|
515
|
+
if (truncated) {
|
|
516
|
+
return {
|
|
517
|
+
contentType,
|
|
518
|
+
buffer: Buffer.alloc(0),
|
|
519
|
+
truncated: true,
|
|
520
|
+
sizeBytes: total
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
524
|
+
return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
|
|
525
|
+
}
|
|
526
|
+
const arrayBuffer = await mapAbort(res.arrayBuffer());
|
|
527
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
528
|
+
return { contentType, buffer, sizeBytes: buffer.length };
|
|
529
|
+
});
|
|
510
530
|
} finally {
|
|
511
|
-
cleanup();
|
|
531
|
+
start.cleanup();
|
|
512
532
|
}
|
|
513
533
|
}
|
|
514
534
|
async function capsulePostBinary(path, body, contentType, filename) {
|
|
515
535
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
516
536
|
const token = getToken();
|
|
517
537
|
const url = buildUrl(path);
|
|
518
|
-
const
|
|
538
|
+
const start = await doFetch(url, {
|
|
519
539
|
method: "POST",
|
|
520
540
|
headers: {
|
|
521
541
|
...baseHeaders(token),
|
|
@@ -526,25 +546,27 @@ async function capsulePostBinary(path, body, contentType, filename) {
|
|
|
526
546
|
body
|
|
527
547
|
});
|
|
528
548
|
try {
|
|
529
|
-
return await handleResponse(res);
|
|
549
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
530
550
|
} finally {
|
|
531
|
-
cleanup();
|
|
551
|
+
start.cleanup();
|
|
532
552
|
}
|
|
533
553
|
}
|
|
534
554
|
async function capsuleDelete(path) {
|
|
535
555
|
if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
|
|
536
556
|
const token = getToken();
|
|
537
557
|
const url = buildUrl(path);
|
|
538
|
-
const
|
|
558
|
+
const start = await doFetch(url, {
|
|
539
559
|
method: "DELETE",
|
|
540
560
|
headers: baseHeaders(token)
|
|
541
561
|
});
|
|
542
562
|
try {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
563
|
+
await consumeBody(start, async () => {
|
|
564
|
+
if (start.res.status === 204) return;
|
|
565
|
+
await throwForStatus(start.res);
|
|
566
|
+
await mapAbort(start.res.text());
|
|
567
|
+
});
|
|
546
568
|
} finally {
|
|
547
|
-
cleanup();
|
|
569
|
+
start.cleanup();
|
|
548
570
|
}
|
|
549
571
|
}
|
|
550
572
|
|
|
@@ -1944,16 +1966,20 @@ var createOpportunitySchema = z5.object({
|
|
|
1944
1966
|
probability: z5.number().int().min(0).max(100).optional(),
|
|
1945
1967
|
ownerId: z5.number().int().positive().optional().describe(
|
|
1946
1968
|
"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."
|
|
1969
|
+
),
|
|
1970
|
+
teamId: z5.number().int().positive().optional().describe(
|
|
1971
|
+
"Assign to team ID (discover via list_teams). Independent from `ownerId` \u2014 setting one does NOT clear the other on create. Three ownership shapes are valid: owner alone, team alone, or owner+team (the owner must be a member of the team; users can belong to multiple teams \u2014 422 'owner is not a member of the team' otherwise)."
|
|
1947
1972
|
)
|
|
1948
1973
|
});
|
|
1949
1974
|
async function createOpportunity(input) {
|
|
1950
|
-
const { partyId, milestoneId, ownerId, ...rest } = input;
|
|
1975
|
+
const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
|
|
1951
1976
|
const body = {
|
|
1952
1977
|
...rest,
|
|
1953
1978
|
party: { id: partyId },
|
|
1954
1979
|
milestone: { id: milestoneId }
|
|
1955
1980
|
};
|
|
1956
1981
|
if (ownerId) body["owner"] = { id: ownerId };
|
|
1982
|
+
if (teamId) body["team"] = { id: teamId };
|
|
1957
1983
|
return capsulePost("/opportunities", { opportunity: body });
|
|
1958
1984
|
}
|
|
1959
1985
|
var updateOpportunitySchema = z5.object({
|
|
@@ -1972,18 +1998,28 @@ var updateOpportunitySchema = z5.object({
|
|
|
1972
1998
|
"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."
|
|
1973
1999
|
),
|
|
1974
2000
|
ownerId: z5.number().int().positive().optional().describe(
|
|
1975
|
-
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
|
|
2001
|
+
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that. When you supply `ownerId` and omit `teamId`, the connector fetches the opportunity's current team and includes it in the PUT body to preserve it across the owner change. Without this defensive read, Capsule's PUT would clear the existing team (see NOTES-ON-CAPSULE-API.md \xA727 \u2014 same asymmetric semantic as /kases). Supply `teamId` explicitly on the same call to change the team instead."
|
|
2002
|
+
),
|
|
2003
|
+
teamId: z5.number().int().positive().nullable().optional().describe(
|
|
2004
|
+
"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_opportunity { 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'. Independent from `ownerId` \u2014 setting `teamId` does NOT clear the owner."
|
|
1976
2005
|
),
|
|
1977
2006
|
fields: z5.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
|
|
1978
2007
|
});
|
|
1979
2008
|
async function updateOpportunity(input) {
|
|
1980
|
-
const { id, milestoneId, ownerId, lostReasonId, fields, ...rest } = input;
|
|
2009
|
+
const { id, milestoneId, ownerId, teamId, lostReasonId, fields, ...rest } = input;
|
|
1981
2010
|
const body = {};
|
|
1982
2011
|
for (const [k, v] of Object.entries(rest)) {
|
|
1983
2012
|
if (v !== void 0) body[k] = v;
|
|
1984
2013
|
}
|
|
1985
2014
|
if (milestoneId) body["milestone"] = { id: milestoneId };
|
|
2015
|
+
let resolvedTeamId = teamId;
|
|
2016
|
+
if (ownerId !== void 0 && teamId === void 0) {
|
|
2017
|
+
const { data } = await capsuleGet(`/opportunities/${id}`);
|
|
2018
|
+
resolvedTeamId = data.opportunity?.team?.id ?? void 0;
|
|
2019
|
+
}
|
|
1986
2020
|
if (ownerId) body["owner"] = { id: ownerId };
|
|
2021
|
+
if (resolvedTeamId === null) body["team"] = null;
|
|
2022
|
+
else if (resolvedTeamId !== void 0) body["team"] = { id: resolvedTeamId };
|
|
1987
2023
|
if (lostReasonId) body["lostReason"] = { id: lostReasonId };
|
|
1988
2024
|
const mappedFields = mapFieldsForBody(fields);
|
|
1989
2025
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
@@ -3051,7 +3087,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
3051
3087
|
const server = new McpServer(
|
|
3052
3088
|
{
|
|
3053
3089
|
name: "capsulemcp",
|
|
3054
|
-
version: "1.6.
|
|
3090
|
+
version: "1.6.1",
|
|
3055
3091
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
3056
3092
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
3057
3093
|
icons: ICONS
|
package/dist/index.js
CHANGED
|
@@ -286,11 +286,22 @@ async function doFetch(url, options) {
|
|
|
286
286
|
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
287
287
|
);
|
|
288
288
|
}
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
return { ...retried, startedAt, method, url, retriedAfter429: true };
|
|
290
|
+
}
|
|
291
|
+
return { ...first, startedAt, method, url, retriedAfter429: false };
|
|
292
|
+
}
|
|
293
|
+
async function consumeBody(start, body) {
|
|
294
|
+
try {
|
|
295
|
+
return await body();
|
|
296
|
+
} finally {
|
|
297
|
+
emitCapsuleRequest(
|
|
298
|
+
start.method,
|
|
299
|
+
start.url,
|
|
300
|
+
start.res,
|
|
301
|
+
Date.now() - start.startedAt,
|
|
302
|
+
start.retriedAfter429
|
|
303
|
+
);
|
|
291
304
|
}
|
|
292
|
-
emitCapsuleRequest(method, url, first.res, Date.now() - startedAt, false);
|
|
293
|
-
return first;
|
|
294
305
|
}
|
|
295
306
|
function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
296
307
|
let path = "";
|
|
@@ -340,13 +351,15 @@ function buildUrl(path, params) {
|
|
|
340
351
|
async function capsuleGet(path, params) {
|
|
341
352
|
const token = getToken();
|
|
342
353
|
const url = buildUrl(path, params);
|
|
343
|
-
const
|
|
354
|
+
const start = await doFetch(url, { headers: baseHeaders(token) });
|
|
344
355
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
356
|
+
return await consumeBody(start, async () => {
|
|
357
|
+
const data = await handleResponse(start.res);
|
|
358
|
+
const nextPage = parseNextPage(start.res.headers.get("Link"));
|
|
359
|
+
return { data, nextPage };
|
|
360
|
+
});
|
|
348
361
|
} finally {
|
|
349
|
-
cleanup();
|
|
362
|
+
start.cleanup();
|
|
350
363
|
}
|
|
351
364
|
}
|
|
352
365
|
async function capsuleGetCached(path, params) {
|
|
@@ -381,123 +394,130 @@ async function capsulePost(path, body) {
|
|
|
381
394
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
382
395
|
const token = getToken();
|
|
383
396
|
const url = buildUrl(path);
|
|
384
|
-
const
|
|
397
|
+
const start = await doFetch(url, {
|
|
385
398
|
method: "POST",
|
|
386
399
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
387
400
|
body: JSON.stringify(body)
|
|
388
401
|
});
|
|
389
402
|
try {
|
|
390
|
-
return await handleResponse(res);
|
|
403
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
391
404
|
} finally {
|
|
392
|
-
cleanup();
|
|
405
|
+
start.cleanup();
|
|
393
406
|
}
|
|
394
407
|
}
|
|
395
408
|
async function capsulePostNoContent(path) {
|
|
396
409
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
397
410
|
const token = getToken();
|
|
398
411
|
const url = buildUrl(path);
|
|
399
|
-
const
|
|
412
|
+
const start = await doFetch(url, {
|
|
400
413
|
method: "POST",
|
|
401
414
|
headers: baseHeaders(token)
|
|
402
415
|
});
|
|
403
416
|
try {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
417
|
+
await consumeBody(start, async () => {
|
|
418
|
+
if (start.res.status === 204) return;
|
|
419
|
+
await throwForStatus(start.res);
|
|
420
|
+
await mapAbort(start.res.text());
|
|
421
|
+
});
|
|
407
422
|
} finally {
|
|
408
|
-
cleanup();
|
|
423
|
+
start.cleanup();
|
|
409
424
|
}
|
|
410
425
|
}
|
|
411
426
|
async function capsuleSearch(path, body, params) {
|
|
412
427
|
const token = getToken();
|
|
413
428
|
const url = buildUrl(path, params);
|
|
414
|
-
const
|
|
429
|
+
const start = await doFetch(url, {
|
|
415
430
|
method: "POST",
|
|
416
431
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
417
432
|
body: JSON.stringify(body)
|
|
418
433
|
});
|
|
419
434
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
435
|
+
return await consumeBody(start, async () => {
|
|
436
|
+
const data = await handleResponse(start.res);
|
|
437
|
+
const nextPage = parseNextPage(start.res.headers.get("Link"));
|
|
438
|
+
return { data, nextPage };
|
|
439
|
+
});
|
|
423
440
|
} finally {
|
|
424
|
-
cleanup();
|
|
441
|
+
start.cleanup();
|
|
425
442
|
}
|
|
426
443
|
}
|
|
427
444
|
async function capsulePut(path, body) {
|
|
428
445
|
if (isReadOnly()) throw new CapsuleReadOnlyError("PUT");
|
|
429
446
|
const token = getToken();
|
|
430
447
|
const url = buildUrl(path);
|
|
431
|
-
const
|
|
448
|
+
const start = await doFetch(url, {
|
|
432
449
|
method: "PUT",
|
|
433
450
|
headers: { ...baseHeaders(token), "Content-Type": "application/json" },
|
|
434
451
|
body: JSON.stringify(body)
|
|
435
452
|
});
|
|
436
453
|
try {
|
|
437
|
-
return await handleResponse(res);
|
|
454
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
438
455
|
} finally {
|
|
439
|
-
cleanup();
|
|
456
|
+
start.cleanup();
|
|
440
457
|
}
|
|
441
458
|
}
|
|
442
459
|
async function capsuleGetBinary(path, maxBytes) {
|
|
443
460
|
const token = getToken();
|
|
444
461
|
const url = buildUrl(path);
|
|
445
|
-
const
|
|
462
|
+
const start = await doFetch(url, { headers: baseHeaders(token) });
|
|
446
463
|
try {
|
|
447
|
-
await
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
buffer: Buffer.alloc(0),
|
|
457
|
-
truncated: true,
|
|
458
|
-
sizeBytes: declaredBytes
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
if (maxBytes !== void 0 && res.body) {
|
|
462
|
-
const reader = res.body.getReader();
|
|
463
|
-
const chunks = [];
|
|
464
|
-
let total = 0;
|
|
465
|
-
let truncated = false;
|
|
466
|
-
while (true) {
|
|
467
|
-
const { done, value } = await mapAbort(reader.read());
|
|
468
|
-
if (done) break;
|
|
469
|
-
total += value.byteLength;
|
|
470
|
-
if (total > maxBytes) {
|
|
471
|
-
truncated = true;
|
|
472
|
-
await reader.cancel().catch(() => {
|
|
473
|
-
});
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
chunks.push(value);
|
|
477
|
-
}
|
|
478
|
-
if (truncated) {
|
|
464
|
+
return await consumeBody(start, async () => {
|
|
465
|
+
const res = start.res;
|
|
466
|
+
await throwForStatus(res);
|
|
467
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
468
|
+
const declared = res.headers.get("Content-Length");
|
|
469
|
+
const declaredBytes = declared ? Number(declared) : NaN;
|
|
470
|
+
if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
471
|
+
if (res.body) await res.body.cancel().catch(() => {
|
|
472
|
+
});
|
|
479
473
|
return {
|
|
480
474
|
contentType,
|
|
481
475
|
buffer: Buffer.alloc(0),
|
|
482
476
|
truncated: true,
|
|
483
|
-
sizeBytes:
|
|
477
|
+
sizeBytes: declaredBytes
|
|
484
478
|
};
|
|
485
479
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
480
|
+
if (maxBytes !== void 0 && res.body) {
|
|
481
|
+
const reader = res.body.getReader();
|
|
482
|
+
const chunks = [];
|
|
483
|
+
let total = 0;
|
|
484
|
+
let truncated = false;
|
|
485
|
+
while (true) {
|
|
486
|
+
const { done, value } = await mapAbort(reader.read());
|
|
487
|
+
if (done) break;
|
|
488
|
+
total += value.byteLength;
|
|
489
|
+
if (total > maxBytes) {
|
|
490
|
+
truncated = true;
|
|
491
|
+
await reader.cancel().catch(() => {
|
|
492
|
+
});
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
chunks.push(value);
|
|
496
|
+
}
|
|
497
|
+
if (truncated) {
|
|
498
|
+
return {
|
|
499
|
+
contentType,
|
|
500
|
+
buffer: Buffer.alloc(0),
|
|
501
|
+
truncated: true,
|
|
502
|
+
sizeBytes: total
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
506
|
+
return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
|
|
507
|
+
}
|
|
508
|
+
const arrayBuffer = await mapAbort(res.arrayBuffer());
|
|
509
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
510
|
+
return { contentType, buffer, sizeBytes: buffer.length };
|
|
511
|
+
});
|
|
492
512
|
} finally {
|
|
493
|
-
cleanup();
|
|
513
|
+
start.cleanup();
|
|
494
514
|
}
|
|
495
515
|
}
|
|
496
516
|
async function capsulePostBinary(path, body, contentType, filename) {
|
|
497
517
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
498
518
|
const token = getToken();
|
|
499
519
|
const url = buildUrl(path);
|
|
500
|
-
const
|
|
520
|
+
const start = await doFetch(url, {
|
|
501
521
|
method: "POST",
|
|
502
522
|
headers: {
|
|
503
523
|
...baseHeaders(token),
|
|
@@ -508,25 +528,27 @@ async function capsulePostBinary(path, body, contentType, filename) {
|
|
|
508
528
|
body
|
|
509
529
|
});
|
|
510
530
|
try {
|
|
511
|
-
return await handleResponse(res);
|
|
531
|
+
return await consumeBody(start, () => handleResponse(start.res));
|
|
512
532
|
} finally {
|
|
513
|
-
cleanup();
|
|
533
|
+
start.cleanup();
|
|
514
534
|
}
|
|
515
535
|
}
|
|
516
536
|
async function capsuleDelete(path) {
|
|
517
537
|
if (isReadOnly()) throw new CapsuleReadOnlyError("DELETE");
|
|
518
538
|
const token = getToken();
|
|
519
539
|
const url = buildUrl(path);
|
|
520
|
-
const
|
|
540
|
+
const start = await doFetch(url, {
|
|
521
541
|
method: "DELETE",
|
|
522
542
|
headers: baseHeaders(token)
|
|
523
543
|
});
|
|
524
544
|
try {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
545
|
+
await consumeBody(start, async () => {
|
|
546
|
+
if (start.res.status === 204) return;
|
|
547
|
+
await throwForStatus(start.res);
|
|
548
|
+
await mapAbort(start.res.text());
|
|
549
|
+
});
|
|
528
550
|
} finally {
|
|
529
|
-
cleanup();
|
|
551
|
+
start.cleanup();
|
|
530
552
|
}
|
|
531
553
|
}
|
|
532
554
|
|
|
@@ -1441,16 +1463,20 @@ var createOpportunitySchema = z4.object({
|
|
|
1441
1463
|
probability: z4.number().int().min(0).max(100).optional(),
|
|
1442
1464
|
ownerId: z4.number().int().positive().optional().describe(
|
|
1443
1465
|
"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."
|
|
1466
|
+
),
|
|
1467
|
+
teamId: z4.number().int().positive().optional().describe(
|
|
1468
|
+
"Assign to team ID (discover via list_teams). Independent from `ownerId` \u2014 setting one does NOT clear the other on create. Three ownership shapes are valid: owner alone, team alone, or owner+team (the owner must be a member of the team; users can belong to multiple teams \u2014 422 'owner is not a member of the team' otherwise)."
|
|
1444
1469
|
)
|
|
1445
1470
|
});
|
|
1446
1471
|
async function createOpportunity(input) {
|
|
1447
|
-
const { partyId, milestoneId, ownerId, ...rest } = input;
|
|
1472
|
+
const { partyId, milestoneId, ownerId, teamId, ...rest } = input;
|
|
1448
1473
|
const body = {
|
|
1449
1474
|
...rest,
|
|
1450
1475
|
party: { id: partyId },
|
|
1451
1476
|
milestone: { id: milestoneId }
|
|
1452
1477
|
};
|
|
1453
1478
|
if (ownerId) body["owner"] = { id: ownerId };
|
|
1479
|
+
if (teamId) body["team"] = { id: teamId };
|
|
1454
1480
|
return capsulePost("/opportunities", { opportunity: body });
|
|
1455
1481
|
}
|
|
1456
1482
|
var updateOpportunitySchema = z4.object({
|
|
@@ -1469,18 +1495,28 @@ var updateOpportunitySchema = z4.object({
|
|
|
1469
1495
|
"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."
|
|
1470
1496
|
),
|
|
1471
1497
|
ownerId: z4.number().int().positive().optional().describe(
|
|
1472
|
-
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that."
|
|
1498
|
+
"Reassign owner to user ID. Once set, this connector cannot clear an owner back to null \u2014 use Capsule's web UI for that. When you supply `ownerId` and omit `teamId`, the connector fetches the opportunity's current team and includes it in the PUT body to preserve it across the owner change. Without this defensive read, Capsule's PUT would clear the existing team (see NOTES-ON-CAPSULE-API.md \xA727 \u2014 same asymmetric semantic as /kases). Supply `teamId` explicitly on the same call to change the team instead."
|
|
1499
|
+
),
|
|
1500
|
+
teamId: z4.number().int().positive().nullable().optional().describe(
|
|
1501
|
+
"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_opportunity { 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'. Independent from `ownerId` \u2014 setting `teamId` does NOT clear the owner."
|
|
1473
1502
|
),
|
|
1474
1503
|
fields: z4.array(CustomFieldWriteSchema).optional().describe(fieldsArrayDescriptor("get_opportunity"))
|
|
1475
1504
|
});
|
|
1476
1505
|
async function updateOpportunity(input) {
|
|
1477
|
-
const { id, milestoneId, ownerId, lostReasonId, fields, ...rest } = input;
|
|
1506
|
+
const { id, milestoneId, ownerId, teamId, lostReasonId, fields, ...rest } = input;
|
|
1478
1507
|
const body = {};
|
|
1479
1508
|
for (const [k, v] of Object.entries(rest)) {
|
|
1480
1509
|
if (v !== void 0) body[k] = v;
|
|
1481
1510
|
}
|
|
1482
1511
|
if (milestoneId) body["milestone"] = { id: milestoneId };
|
|
1512
|
+
let resolvedTeamId = teamId;
|
|
1513
|
+
if (ownerId !== void 0 && teamId === void 0) {
|
|
1514
|
+
const { data } = await capsuleGet(`/opportunities/${id}`);
|
|
1515
|
+
resolvedTeamId = data.opportunity?.team?.id ?? void 0;
|
|
1516
|
+
}
|
|
1483
1517
|
if (ownerId) body["owner"] = { id: ownerId };
|
|
1518
|
+
if (resolvedTeamId === null) body["team"] = null;
|
|
1519
|
+
else if (resolvedTeamId !== void 0) body["team"] = { id: resolvedTeamId };
|
|
1484
1520
|
if (lostReasonId) body["lostReason"] = { id: lostReasonId };
|
|
1485
1521
|
const mappedFields = mapFieldsForBody(fields);
|
|
1486
1522
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
@@ -2548,7 +2584,7 @@ function createCapsuleMcpServer(opts) {
|
|
|
2548
2584
|
const server2 = new McpServer(
|
|
2549
2585
|
{
|
|
2550
2586
|
name: "capsulemcp",
|
|
2551
|
-
version: "1.6.
|
|
2587
|
+
version: "1.6.1",
|
|
2552
2588
|
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
2553
2589
|
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
2554
2590
|
icons: ICONS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capsulemcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Model Context Protocol server for Capsule CRM. Lets Claude (Desktop, Code, or web Projects via Custom Connector) read and write your CRM in plain English. Covers contacts, opportunities, projects, tasks, timeline activity, structured filters, saved filters with sort, workflow tracks, file attachments, audit, and batch fetches.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|