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 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.0"` in `args`. If you're tracking a fork or an unreleased branch, use the GitHub-ref form instead: `"github:soil-dev/capsulemcp#v1.6.0"` — 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
+ 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
- emitCapsuleRequest(method, url, retried.res, Date.now() - startedAt, true);
308
- return retried;
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 { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
372
+ const start = await doFetch(url, { headers: baseHeaders(token) });
362
373
  try {
363
- const data = await handleResponse(res);
364
- const nextPage = parseNextPage(res.headers.get("Link"));
365
- return { data, nextPage };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, {
430
+ const start = await doFetch(url, {
418
431
  method: "POST",
419
432
  headers: baseHeaders(token)
420
433
  });
421
434
  try {
422
- if (res.status === 204) return;
423
- await throwForStatus(res);
424
- await mapAbort(res.text());
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 { res, cleanup } = await doFetch(url, {
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
- const data = await handleResponse(res);
439
- const nextPage = parseNextPage(res.headers.get("Link"));
440
- return { data, nextPage };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
480
+ const start = await doFetch(url, { headers: baseHeaders(token) });
464
481
  try {
465
- await throwForStatus(res);
466
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
467
- const declared = res.headers.get("Content-Length");
468
- const declaredBytes = declared ? Number(declared) : NaN;
469
- if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
470
- if (res.body) await res.body.cancel().catch(() => {
471
- });
472
- return {
473
- contentType,
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: total
495
+ sizeBytes: declaredBytes
502
496
  };
503
497
  }
504
- const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
505
- return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
506
- }
507
- const arrayBuffer = await mapAbort(res.arrayBuffer());
508
- const buffer = Buffer.from(arrayBuffer);
509
- return { contentType, buffer, sizeBytes: buffer.length };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, {
558
+ const start = await doFetch(url, {
539
559
  method: "DELETE",
540
560
  headers: baseHeaders(token)
541
561
  });
542
562
  try {
543
- if (res.status === 204) return;
544
- await throwForStatus(res);
545
- await mapAbort(res.text());
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.0",
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
- emitCapsuleRequest(method, url, retried.res, Date.now() - startedAt, true);
290
- return retried;
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 { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
354
+ const start = await doFetch(url, { headers: baseHeaders(token) });
344
355
  try {
345
- const data = await handleResponse(res);
346
- const nextPage = parseNextPage(res.headers.get("Link"));
347
- return { data, nextPage };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, {
412
+ const start = await doFetch(url, {
400
413
  method: "POST",
401
414
  headers: baseHeaders(token)
402
415
  });
403
416
  try {
404
- if (res.status === 204) return;
405
- await throwForStatus(res);
406
- await mapAbort(res.text());
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 { res, cleanup } = await doFetch(url, {
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
- const data = await handleResponse(res);
421
- const nextPage = parseNextPage(res.headers.get("Link"));
422
- return { data, nextPage };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, { headers: baseHeaders(token) });
462
+ const start = await doFetch(url, { headers: baseHeaders(token) });
446
463
  try {
447
- await throwForStatus(res);
448
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
449
- const declared = res.headers.get("Content-Length");
450
- const declaredBytes = declared ? Number(declared) : NaN;
451
- if (maxBytes !== void 0 && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
452
- if (res.body) await res.body.cancel().catch(() => {
453
- });
454
- return {
455
- contentType,
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: total
477
+ sizeBytes: declaredBytes
484
478
  };
485
479
  }
486
- const buffer2 = Buffer.concat(chunks.map((c) => Buffer.from(c)));
487
- return { contentType, buffer: buffer2, sizeBytes: buffer2.length };
488
- }
489
- const arrayBuffer = await mapAbort(res.arrayBuffer());
490
- const buffer = Buffer.from(arrayBuffer);
491
- return { contentType, buffer, sizeBytes: buffer.length };
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 { res, cleanup } = await doFetch(url, {
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 { res, cleanup } = await doFetch(url, {
540
+ const start = await doFetch(url, {
521
541
  method: "DELETE",
522
542
  headers: baseHeaders(token)
523
543
  });
524
544
  try {
525
- if (res.status === 204) return;
526
- await throwForStatus(res);
527
- await mapAbort(res.text());
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.0",
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.0",
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",