capsulemcp 1.0.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/http.js +824 -92
- package/dist/index.js +773 -84
- package/package.json +10 -5
package/dist/http.js
CHANGED
|
@@ -1,5 +1,146 @@
|
|
|
1
1
|
// src/capsule/client.ts
|
|
2
2
|
import { fetch } from "undici";
|
|
3
|
+
|
|
4
|
+
// src/env.ts
|
|
5
|
+
function readBool(name) {
|
|
6
|
+
const raw = process.env[name]?.toLowerCase();
|
|
7
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
8
|
+
}
|
|
9
|
+
function readPositiveInt(name, fallback, min = 1) {
|
|
10
|
+
const raw = process.env[name];
|
|
11
|
+
if (raw === void 0 || raw === "") return fallback;
|
|
12
|
+
const n = Number(raw);
|
|
13
|
+
if (!Number.isFinite(n) || n < min) return fallback;
|
|
14
|
+
return Math.floor(n);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/log.ts
|
|
18
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
19
|
+
function logVerbose() {
|
|
20
|
+
return readBool("CAPSULE_MCP_LOG_VERBOSE");
|
|
21
|
+
}
|
|
22
|
+
var chainHandlers = {
|
|
23
|
+
"tool.call": (ctx, f) => {
|
|
24
|
+
if (typeof f["tool"] === "string") ctx.tools.push(f["tool"]);
|
|
25
|
+
},
|
|
26
|
+
"capsule.request": (ctx) => {
|
|
27
|
+
ctx.capsuleCalls += 1;
|
|
28
|
+
},
|
|
29
|
+
// Cache-hit events feed the aggregate so the chain stat is right
|
|
30
|
+
// even on tools whose Capsule calls all hit the cache.
|
|
31
|
+
"cache.hit": (ctx) => {
|
|
32
|
+
ctx.cacheHits += 1;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function logEvent(event, fields, opts = {}) {
|
|
36
|
+
const ctx = requestContext.getStore();
|
|
37
|
+
if (ctx) chainHandlers[event]?.(ctx, fields);
|
|
38
|
+
if (!opts.force && !logVerbose()) return;
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
`${JSON.stringify({ event, ...fields, timestamp: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
41
|
+
`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function redactPath(path) {
|
|
45
|
+
const noQuery = path.split("?")[0] ?? path;
|
|
46
|
+
return noQuery.replace(/\/\d+(?:,\d+)*/g, "/:id");
|
|
47
|
+
}
|
|
48
|
+
var requestContext = new AsyncLocalStorage();
|
|
49
|
+
function withRequestContext(initial, fn) {
|
|
50
|
+
const ctx = {
|
|
51
|
+
...initial,
|
|
52
|
+
tools: [],
|
|
53
|
+
capsuleCalls: 0,
|
|
54
|
+
cacheHits: 0,
|
|
55
|
+
startedAt: Date.now()
|
|
56
|
+
};
|
|
57
|
+
return requestContext.run(ctx, async () => {
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
} finally {
|
|
61
|
+
logEvent("tool.chain", {
|
|
62
|
+
...ctx.clientId ? { clientId: ctx.clientId } : {},
|
|
63
|
+
tools: ctx.tools,
|
|
64
|
+
toolCount: ctx.tools.length,
|
|
65
|
+
capsuleCalls: ctx.capsuleCalls,
|
|
66
|
+
cacheHits: ctx.cacheHits,
|
|
67
|
+
durationMs: Date.now() - ctx.startedAt
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function getRequestContext() {
|
|
73
|
+
return requestContext.getStore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/capsule/cache.ts
|
|
77
|
+
var cache = /* @__PURE__ */ new Map();
|
|
78
|
+
var MAX_ENTRIES = 64;
|
|
79
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
80
|
+
function getCacheTtlMs() {
|
|
81
|
+
return readPositiveInt("CAPSULE_MCP_CACHE_TTL_MS", DEFAULT_TTL_MS, 0);
|
|
82
|
+
}
|
|
83
|
+
function explicitlyDisabled() {
|
|
84
|
+
return readBool("CAPSULE_MCP_CACHE_DISABLED");
|
|
85
|
+
}
|
|
86
|
+
function cacheDisabled() {
|
|
87
|
+
return explicitlyDisabled() || getCacheTtlMs() === 0;
|
|
88
|
+
}
|
|
89
|
+
function cacheKey(path, params) {
|
|
90
|
+
if (!params) return `GET ${path}`;
|
|
91
|
+
const entries = Object.entries(params).filter(([, v]) => v !== void 0);
|
|
92
|
+
if (entries.length === 0) return `GET ${path}`;
|
|
93
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
94
|
+
const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
95
|
+
return `GET ${path}?${qs}`;
|
|
96
|
+
}
|
|
97
|
+
function cacheLookup(key) {
|
|
98
|
+
const entry = cache.get(key);
|
|
99
|
+
if (!entry) return { hit: false, reason: "empty" };
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (entry.expiresAt < now) {
|
|
102
|
+
cache.delete(key);
|
|
103
|
+
return { hit: false, reason: "expired" };
|
|
104
|
+
}
|
|
105
|
+
return { hit: true, result: entry.result, ageMs: now - entry.storedAt };
|
|
106
|
+
}
|
|
107
|
+
function cacheSet(key, result) {
|
|
108
|
+
if (cacheDisabled()) return;
|
|
109
|
+
const ttl = getCacheTtlMs();
|
|
110
|
+
while (cache.size >= MAX_ENTRIES) {
|
|
111
|
+
const oldest = cache.keys().next().value;
|
|
112
|
+
if (oldest === void 0) break;
|
|
113
|
+
cache.delete(oldest);
|
|
114
|
+
const evictedKey = `GET ${redactPath(oldest.replace(/^GET /, ""))}`;
|
|
115
|
+
logEvent("cache.evict", { evictedKey, cacheSize: cache.size, reason: "cap" });
|
|
116
|
+
}
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
cache.set(key, {
|
|
119
|
+
result,
|
|
120
|
+
storedAt: now,
|
|
121
|
+
expiresAt: now + ttl
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function invalidateByPrefix(pathPrefix, trigger) {
|
|
125
|
+
const needle = `GET ${pathPrefix}`;
|
|
126
|
+
let droppedCount = 0;
|
|
127
|
+
for (const k of cache.keys()) {
|
|
128
|
+
if (k === needle || k.startsWith(`${needle}?`) || k.startsWith(`${needle}/`)) {
|
|
129
|
+
cache.delete(k);
|
|
130
|
+
droppedCount++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (droppedCount > 0) {
|
|
134
|
+
logEvent("cache.invalidate", {
|
|
135
|
+
prefix: pathPrefix,
|
|
136
|
+
droppedCount,
|
|
137
|
+
cacheSize: cache.size,
|
|
138
|
+
...trigger ? { trigger } : {}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/capsule/client.ts
|
|
3
144
|
var DEFAULT_BASE_URL = "https://api.capsulecrm.com/api/v2";
|
|
4
145
|
function baseUrl() {
|
|
5
146
|
const override = process.env["CAPSULE_API_BASE_URL"];
|
|
@@ -19,8 +160,7 @@ function baseUrl() {
|
|
|
19
160
|
return override;
|
|
20
161
|
}
|
|
21
162
|
function isReadOnly() {
|
|
22
|
-
|
|
23
|
-
return v === "1" || v === "true" || v === "yes";
|
|
163
|
+
return readBool("CAPSULE_MCP_READONLY");
|
|
24
164
|
}
|
|
25
165
|
var CapsuleReadOnlyError = class extends Error {
|
|
26
166
|
constructor(method) {
|
|
@@ -149,6 +289,8 @@ async function fetchWithTimeout(url, options) {
|
|
|
149
289
|
}
|
|
150
290
|
}
|
|
151
291
|
async function doFetch(url, options) {
|
|
292
|
+
const startedAt = Date.now();
|
|
293
|
+
const method = options?.method ?? "GET";
|
|
152
294
|
const first = await fetchWithTimeout(url, options);
|
|
153
295
|
if (first.res.status === 429) {
|
|
154
296
|
const delay = parseRateLimitDelay(first.res);
|
|
@@ -162,10 +304,30 @@ async function doFetch(url, options) {
|
|
|
162
304
|
"Rate limit exceeded after one retry. Please slow down your requests."
|
|
163
305
|
);
|
|
164
306
|
}
|
|
307
|
+
emitCapsuleRequest(method, url, retried.res, Date.now() - startedAt, true);
|
|
165
308
|
return retried;
|
|
166
309
|
}
|
|
310
|
+
emitCapsuleRequest(method, url, first.res, Date.now() - startedAt, false);
|
|
167
311
|
return first;
|
|
168
312
|
}
|
|
313
|
+
function emitCapsuleRequest(method, url, res, durationMs, retriedAfter429) {
|
|
314
|
+
let path = "";
|
|
315
|
+
try {
|
|
316
|
+
path = redactPath(new URL(url).pathname);
|
|
317
|
+
} catch {
|
|
318
|
+
path = "?";
|
|
319
|
+
}
|
|
320
|
+
const lenHeader = res.headers.get("content-length");
|
|
321
|
+
const responseBytes = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
|
|
322
|
+
logEvent("capsule.request", {
|
|
323
|
+
method,
|
|
324
|
+
path,
|
|
325
|
+
status: res.status,
|
|
326
|
+
durationMs,
|
|
327
|
+
responseBytes: Number.isFinite(responseBytes) ? responseBytes : 0,
|
|
328
|
+
...retriedAfter429 ? { retriedAfter429: true } : {}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
169
331
|
async function throwForStatus(res) {
|
|
170
332
|
if (res.status === 401) {
|
|
171
333
|
const detail = await parseErrorBody(res);
|
|
@@ -205,6 +367,34 @@ async function capsuleGet(path, params) {
|
|
|
205
367
|
cleanup();
|
|
206
368
|
}
|
|
207
369
|
}
|
|
370
|
+
async function capsuleGetCached(path, params) {
|
|
371
|
+
if (cacheDisabled()) return capsuleGet(path, params);
|
|
372
|
+
const key = cacheKey(path, params);
|
|
373
|
+
const lookup = cacheLookup(key);
|
|
374
|
+
if (lookup.hit) {
|
|
375
|
+
if (logVerbose()) {
|
|
376
|
+
logEvent("cache.hit", {
|
|
377
|
+
path: redactPath(path),
|
|
378
|
+
...params ? { paramFields: Object.keys(params) } : {},
|
|
379
|
+
ageMs: lookup.ageMs
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return lookup.result;
|
|
383
|
+
}
|
|
384
|
+
const fetchStart = Date.now();
|
|
385
|
+
const result = await capsuleGet(path, params);
|
|
386
|
+
const latencyMs = Date.now() - fetchStart;
|
|
387
|
+
cacheSet(key, result);
|
|
388
|
+
if (logVerbose()) {
|
|
389
|
+
logEvent("cache.miss", {
|
|
390
|
+
path: redactPath(path),
|
|
391
|
+
...params ? { paramFields: Object.keys(params) } : {},
|
|
392
|
+
reason: lookup.reason,
|
|
393
|
+
latencyMs
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
208
398
|
async function capsulePost(path, body) {
|
|
209
399
|
if (isReadOnly()) throw new CapsuleReadOnlyError("POST");
|
|
210
400
|
const token = getToken();
|
|
@@ -834,7 +1024,7 @@ function resolveBaseConfig(env = process.env) {
|
|
|
834
1024
|
// src/http/app.ts
|
|
835
1025
|
import { createHash as createHash2, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
836
1026
|
import express from "express";
|
|
837
|
-
import { rateLimit } from "express-rate-limit";
|
|
1027
|
+
import { ipKeyGenerator, rateLimit } from "express-rate-limit";
|
|
838
1028
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
839
1029
|
import {
|
|
840
1030
|
mcpAuthRouter,
|
|
@@ -876,7 +1066,195 @@ var ICONS = [
|
|
|
876
1066
|
}
|
|
877
1067
|
];
|
|
878
1068
|
|
|
1069
|
+
// src/tasks/store.ts
|
|
1070
|
+
import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
|
|
1071
|
+
import {
|
|
1072
|
+
ErrorCode,
|
|
1073
|
+
McpError
|
|
1074
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
1075
|
+
|
|
1076
|
+
// src/tasks/config.ts
|
|
1077
|
+
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
1078
|
+
var DEFAULT_MAX_KEEP_ALIVE_MS = 15 * 60 * 1e3;
|
|
1079
|
+
var MIN_TASK_TTL_MS = 1e3;
|
|
1080
|
+
var DEFAULT_POLL_FREQUENCY_MS = 1500;
|
|
1081
|
+
var MIN_POLL_FREQUENCY_MS = 500;
|
|
1082
|
+
var DEFAULT_MAX_PER_CLIENT = 20;
|
|
1083
|
+
var DEFAULT_MAX_TOTAL = 200;
|
|
1084
|
+
function getTasksConfig() {
|
|
1085
|
+
const enabled = readBool("MCP_TASKS_ENABLED");
|
|
1086
|
+
const maxKeepAliveMs = Math.max(
|
|
1087
|
+
readPositiveInt("MCP_TASKS_MAX_KEEP_ALIVE_MS", DEFAULT_MAX_KEEP_ALIVE_MS),
|
|
1088
|
+
MIN_TASK_TTL_MS
|
|
1089
|
+
);
|
|
1090
|
+
const defaultTtlMs = Math.min(
|
|
1091
|
+
Math.max(readPositiveInt("MCP_TASKS_DEFAULT_TTL_MS", DEFAULT_TTL_MS2), MIN_TASK_TTL_MS),
|
|
1092
|
+
maxKeepAliveMs
|
|
1093
|
+
);
|
|
1094
|
+
const defaultPollFrequencyMs = Math.max(
|
|
1095
|
+
readPositiveInt("MCP_TASKS_DEFAULT_POLL_FREQUENCY_MS", DEFAULT_POLL_FREQUENCY_MS),
|
|
1096
|
+
MIN_POLL_FREQUENCY_MS
|
|
1097
|
+
);
|
|
1098
|
+
const maxPerClient = readPositiveInt("MCP_TASKS_MAX_PER_CLIENT", DEFAULT_MAX_PER_CLIENT);
|
|
1099
|
+
const maxTotal = readPositiveInt("MCP_TASKS_MAX_TOTAL", DEFAULT_MAX_TOTAL);
|
|
1100
|
+
return {
|
|
1101
|
+
enabled,
|
|
1102
|
+
defaultTtlMs,
|
|
1103
|
+
maxKeepAliveMs,
|
|
1104
|
+
defaultPollFrequencyMs,
|
|
1105
|
+
maxPerClient,
|
|
1106
|
+
maxTotal
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// src/tasks/store.ts
|
|
1111
|
+
var _globalStore = null;
|
|
1112
|
+
function getGlobalStore() {
|
|
1113
|
+
if (_globalStore === null) {
|
|
1114
|
+
_globalStore = new InMemoryTaskStore();
|
|
1115
|
+
}
|
|
1116
|
+
return _globalStore;
|
|
1117
|
+
}
|
|
1118
|
+
var owners = /* @__PURE__ */ new Map();
|
|
1119
|
+
var abortControllers = /* @__PURE__ */ new Map();
|
|
1120
|
+
function registerAbortController(taskId, controller) {
|
|
1121
|
+
abortControllers.set(taskId, controller);
|
|
1122
|
+
}
|
|
1123
|
+
function countPerClient(clientId) {
|
|
1124
|
+
let n = 0;
|
|
1125
|
+
for (const owner of owners.values()) {
|
|
1126
|
+
if (owner === clientId) n++;
|
|
1127
|
+
}
|
|
1128
|
+
return n;
|
|
1129
|
+
}
|
|
1130
|
+
function createScopedTaskStore(clientId) {
|
|
1131
|
+
if (!clientId) {
|
|
1132
|
+
throw new Error("createScopedTaskStore: clientId is required");
|
|
1133
|
+
}
|
|
1134
|
+
const global = getGlobalStore();
|
|
1135
|
+
async function getOwned(taskId) {
|
|
1136
|
+
if (owners.get(taskId) !== clientId) return null;
|
|
1137
|
+
return global.getTask(taskId);
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
async createTask(taskParams, requestId, request, sessionId) {
|
|
1141
|
+
const cfg = getTasksConfig();
|
|
1142
|
+
const totalNow = owners.size;
|
|
1143
|
+
if (totalNow >= cfg.maxTotal) {
|
|
1144
|
+
logEvent("task.rejected", {
|
|
1145
|
+
reason: "max_total",
|
|
1146
|
+
clientId,
|
|
1147
|
+
totalNow,
|
|
1148
|
+
cap: cfg.maxTotal
|
|
1149
|
+
});
|
|
1150
|
+
throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this server instance");
|
|
1151
|
+
}
|
|
1152
|
+
const perClientNow = countPerClient(clientId);
|
|
1153
|
+
if (perClientNow >= cfg.maxPerClient) {
|
|
1154
|
+
logEvent("task.rejected", {
|
|
1155
|
+
reason: "max_per_client",
|
|
1156
|
+
clientId,
|
|
1157
|
+
perClientNow,
|
|
1158
|
+
cap: cfg.maxPerClient
|
|
1159
|
+
});
|
|
1160
|
+
throw new McpError(ErrorCode.InvalidParams, "Task quota exceeded for this client");
|
|
1161
|
+
}
|
|
1162
|
+
const requestedTtl = taskParams.ttl;
|
|
1163
|
+
const clampedTtl = requestedTtl === null ? cfg.maxKeepAliveMs : Math.max(
|
|
1164
|
+
MIN_TASK_TTL_MS,
|
|
1165
|
+
Math.min(requestedTtl ?? cfg.defaultTtlMs, cfg.maxKeepAliveMs)
|
|
1166
|
+
);
|
|
1167
|
+
const requestedPoll = taskParams.pollInterval ?? cfg.defaultPollFrequencyMs;
|
|
1168
|
+
const clampedPoll = Math.max(cfg.defaultPollFrequencyMs, Math.floor(requestedPoll));
|
|
1169
|
+
const task = await global.createTask(
|
|
1170
|
+
{ ttl: clampedTtl, pollInterval: clampedPoll, context: taskParams.context },
|
|
1171
|
+
requestId,
|
|
1172
|
+
request,
|
|
1173
|
+
sessionId
|
|
1174
|
+
);
|
|
1175
|
+
owners.set(task.taskId, clientId);
|
|
1176
|
+
const timer = setTimeout(() => {
|
|
1177
|
+
owners.delete(task.taskId);
|
|
1178
|
+
abortControllers.delete(task.taskId);
|
|
1179
|
+
logEvent("task.evicted", { taskId: task.taskId, clientId, reason: "ttl" });
|
|
1180
|
+
}, clampedTtl);
|
|
1181
|
+
timer.unref?.();
|
|
1182
|
+
logEvent("task.created", {
|
|
1183
|
+
taskId: task.taskId,
|
|
1184
|
+
clientId,
|
|
1185
|
+
ttl: clampedTtl,
|
|
1186
|
+
pollInterval: clampedPoll,
|
|
1187
|
+
method: typeof request.method === "string" ? request.method : "unknown"
|
|
1188
|
+
});
|
|
1189
|
+
return task;
|
|
1190
|
+
},
|
|
1191
|
+
async getTask(taskId) {
|
|
1192
|
+
return getOwned(taskId);
|
|
1193
|
+
},
|
|
1194
|
+
async storeTaskResult(taskId, status, result, sessionId) {
|
|
1195
|
+
if (owners.get(taskId) !== clientId) {
|
|
1196
|
+
throw new McpError(ErrorCode.InvalidParams, "Task not found");
|
|
1197
|
+
}
|
|
1198
|
+
logEvent("task.transition", { taskId, clientId, status });
|
|
1199
|
+
await global.storeTaskResult(taskId, status, result, sessionId);
|
|
1200
|
+
},
|
|
1201
|
+
async getTaskResult(taskId, sessionId) {
|
|
1202
|
+
if (owners.get(taskId) !== clientId) {
|
|
1203
|
+
throw new McpError(ErrorCode.InvalidParams, "Task not found");
|
|
1204
|
+
}
|
|
1205
|
+
return global.getTaskResult(taskId, sessionId);
|
|
1206
|
+
},
|
|
1207
|
+
async updateTaskStatus(taskId, status, statusMessage, sessionId) {
|
|
1208
|
+
if (owners.get(taskId) !== clientId) {
|
|
1209
|
+
throw new McpError(ErrorCode.InvalidParams, "Task not found");
|
|
1210
|
+
}
|
|
1211
|
+
logEvent("task.transition", { taskId, clientId, status, statusMessage });
|
|
1212
|
+
await global.updateTaskStatus(taskId, status, statusMessage, sessionId);
|
|
1213
|
+
if (status === "cancelled") {
|
|
1214
|
+
const ctrl = abortControllers.get(taskId);
|
|
1215
|
+
if (ctrl && !ctrl.signal.aborted) ctrl.abort();
|
|
1216
|
+
}
|
|
1217
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
1218
|
+
abortControllers.delete(taskId);
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
async listTasks(cursor, sessionId) {
|
|
1222
|
+
const page = await global.listTasks(cursor, sessionId);
|
|
1223
|
+
const filtered = page.tasks.filter((t) => owners.get(t.taskId) === clientId);
|
|
1224
|
+
return page.nextCursor ? { tasks: filtered, nextCursor: page.nextCursor } : { tasks: filtered };
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
879
1229
|
// src/server/register-tool.ts
|
|
1230
|
+
var READ_PREFIXES = ["search_", "filter_", "get_", "list_", "show_", "run_"];
|
|
1231
|
+
var DESTRUCTIVE_NON_DELETE = /* @__PURE__ */ new Set(["remove_track", "remove_additional_party"]);
|
|
1232
|
+
function isDestructive(name) {
|
|
1233
|
+
return name.startsWith("delete_") || DESTRUCTIVE_NON_DELETE.has(name);
|
|
1234
|
+
}
|
|
1235
|
+
function inferAnnotations(name) {
|
|
1236
|
+
if (READ_PREFIXES.some((p) => name.startsWith(p))) {
|
|
1237
|
+
return { readOnlyHint: true };
|
|
1238
|
+
}
|
|
1239
|
+
if (isDestructive(name)) {
|
|
1240
|
+
return { destructiveHint: true };
|
|
1241
|
+
}
|
|
1242
|
+
return void 0;
|
|
1243
|
+
}
|
|
1244
|
+
function argFieldNames(input) {
|
|
1245
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
|
|
1246
|
+
return Object.keys(input);
|
|
1247
|
+
}
|
|
1248
|
+
function emitToolCall(opts) {
|
|
1249
|
+
logEvent("tool.call", {
|
|
1250
|
+
tool: opts.tool,
|
|
1251
|
+
...opts.clientId ? { clientId: opts.clientId } : {},
|
|
1252
|
+
argFields: opts.argFields,
|
|
1253
|
+
durationMs: Date.now() - opts.startedAt,
|
|
1254
|
+
outcome: opts.outcome,
|
|
1255
|
+
...opts.taskAugmented ? { taskAugmented: true } : {}
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
880
1258
|
function wrapAsText(result) {
|
|
881
1259
|
return {
|
|
882
1260
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
@@ -884,10 +1262,93 @@ function wrapAsText(result) {
|
|
|
884
1262
|
}
|
|
885
1263
|
function registerTool(server, name, description, schema, handler) {
|
|
886
1264
|
const registerWithSchema = server.registerTool.bind(server);
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1265
|
+
const annotations = inferAnnotations(name);
|
|
1266
|
+
registerWithSchema(
|
|
1267
|
+
name,
|
|
1268
|
+
{ description, inputSchema: schema, ...annotations ? { annotations } : {} },
|
|
1269
|
+
async (input) => {
|
|
1270
|
+
const startedAt = Date.now();
|
|
1271
|
+
const argFields = argFieldNames(input);
|
|
1272
|
+
const clientId = getRequestContext()?.clientId;
|
|
1273
|
+
try {
|
|
1274
|
+
const result = await handler(input);
|
|
1275
|
+
emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "success" });
|
|
1276
|
+
return wrapAsText(result);
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
emitToolCall({ tool: name, clientId, argFields, startedAt, outcome: "error" });
|
|
1279
|
+
throw err;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
function registerToolTask(server, name, description, schema, handler) {
|
|
1285
|
+
const registerWithSchema = server.experimental.tasks.registerToolTask.bind(
|
|
1286
|
+
server.experimental.tasks
|
|
1287
|
+
);
|
|
1288
|
+
const annotations = inferAnnotations(name);
|
|
1289
|
+
registerWithSchema(
|
|
1290
|
+
name,
|
|
1291
|
+
{
|
|
1292
|
+
description,
|
|
1293
|
+
inputSchema: schema,
|
|
1294
|
+
execution: { taskSupport: "optional" },
|
|
1295
|
+
...annotations ? { annotations } : {}
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
createTask: async (input, extra) => {
|
|
1299
|
+
const task = await extra.taskStore.createTask({
|
|
1300
|
+
ttl: extra.taskRequestedTtl
|
|
1301
|
+
});
|
|
1302
|
+
const abortController = new AbortController();
|
|
1303
|
+
registerAbortController(task.taskId, abortController);
|
|
1304
|
+
const requestClientId = getRequestContext()?.clientId;
|
|
1305
|
+
const argFields = argFieldNames(input);
|
|
1306
|
+
void (async () => {
|
|
1307
|
+
if (abortController.signal.aborted) return;
|
|
1308
|
+
try {
|
|
1309
|
+
await extra.taskStore.updateTaskStatus(task.taskId, "working");
|
|
1310
|
+
} catch {
|
|
1311
|
+
}
|
|
1312
|
+
const handlerStart = Date.now();
|
|
1313
|
+
let payload;
|
|
1314
|
+
let outcome = "success";
|
|
1315
|
+
try {
|
|
1316
|
+
const result = await handler(input, {
|
|
1317
|
+
signal: abortController.signal
|
|
1318
|
+
});
|
|
1319
|
+
payload = wrapAsText(result);
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
if (abortController.signal.aborted) return;
|
|
1322
|
+
outcome = "error";
|
|
1323
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1324
|
+
payload = {
|
|
1325
|
+
content: [{ type: "text", text: message }],
|
|
1326
|
+
isError: true
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
emitToolCall({
|
|
1330
|
+
tool: name,
|
|
1331
|
+
clientId: requestClientId,
|
|
1332
|
+
argFields,
|
|
1333
|
+
startedAt: handlerStart,
|
|
1334
|
+
outcome,
|
|
1335
|
+
taskAugmented: true
|
|
1336
|
+
});
|
|
1337
|
+
if (abortController.signal.aborted) return;
|
|
1338
|
+
try {
|
|
1339
|
+
await extra.taskStore.storeTaskResult(task.taskId, "completed", payload);
|
|
1340
|
+
} catch {
|
|
1341
|
+
}
|
|
1342
|
+
})();
|
|
1343
|
+
return { task };
|
|
1344
|
+
},
|
|
1345
|
+
getTask: async (_input, extra) => extra.taskStore.getTask(extra.taskId),
|
|
1346
|
+
getTaskResult: async (_input, extra) => {
|
|
1347
|
+
const r = await extra.taskStore.getTaskResult(extra.taskId);
|
|
1348
|
+
return r;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
);
|
|
891
1352
|
}
|
|
892
1353
|
|
|
893
1354
|
// src/tools/parties.ts
|
|
@@ -904,6 +1365,98 @@ function confirmFlag() {
|
|
|
904
1365
|
return z2.literal(true, { error: () => CONFIRM_REQUIRED_MESSAGE });
|
|
905
1366
|
}
|
|
906
1367
|
|
|
1368
|
+
// src/capsule/batch.ts
|
|
1369
|
+
function chunk(arr, size) {
|
|
1370
|
+
if (size <= 0) throw new Error("chunk size must be positive");
|
|
1371
|
+
const out = [];
|
|
1372
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
1373
|
+
out.push(arr.slice(i, i + size));
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
}
|
|
1377
|
+
var DEFAULT_CONCURRENCY = 5;
|
|
1378
|
+
var MAX_CONCURRENCY = 50;
|
|
1379
|
+
function getBatchConcurrency() {
|
|
1380
|
+
return Math.min(
|
|
1381
|
+
readPositiveInt("CAPSULE_MCP_BATCH_CONCURRENCY", DEFAULT_CONCURRENCY),
|
|
1382
|
+
MAX_CONCURRENCY
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
async function batchExecute(tool, items, action, options = {}) {
|
|
1386
|
+
const concurrency = getBatchConcurrency();
|
|
1387
|
+
const results = new Array(items.length);
|
|
1388
|
+
const startedAt = Date.now();
|
|
1389
|
+
const signal = options.signal;
|
|
1390
|
+
let cursor = 0;
|
|
1391
|
+
async function worker() {
|
|
1392
|
+
while (true) {
|
|
1393
|
+
const i = cursor;
|
|
1394
|
+
cursor += 1;
|
|
1395
|
+
if (i >= items.length) return;
|
|
1396
|
+
if (signal?.aborted) {
|
|
1397
|
+
results[i] = {
|
|
1398
|
+
ok: false,
|
|
1399
|
+
error: { message: "cancelled by tasks/cancel" }
|
|
1400
|
+
};
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
const result = await action(items[i], i);
|
|
1405
|
+
results[i] = { ok: true, result };
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
results[i] = { ok: false, error: extractError(err) };
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const workers = [];
|
|
1412
|
+
for (let w = 0; w < Math.min(concurrency, items.length); w++) {
|
|
1413
|
+
workers.push(worker());
|
|
1414
|
+
}
|
|
1415
|
+
await Promise.all(workers);
|
|
1416
|
+
const succeeded = results.filter((r) => r.ok).length;
|
|
1417
|
+
const failed = results.length - succeeded;
|
|
1418
|
+
const summary = { total: results.length, succeeded, failed };
|
|
1419
|
+
const failureReasons = logVerbose() ? topFailureReasons(results, 5) : [];
|
|
1420
|
+
logEvent(
|
|
1421
|
+
"batch.complete",
|
|
1422
|
+
{
|
|
1423
|
+
tool,
|
|
1424
|
+
total: summary.total,
|
|
1425
|
+
succeeded: summary.succeeded,
|
|
1426
|
+
failed: summary.failed,
|
|
1427
|
+
durationMs: Date.now() - startedAt,
|
|
1428
|
+
concurrency,
|
|
1429
|
+
...failureReasons.length > 0 ? { failureReasons } : {}
|
|
1430
|
+
},
|
|
1431
|
+
{ force: true }
|
|
1432
|
+
);
|
|
1433
|
+
return { results, summary };
|
|
1434
|
+
}
|
|
1435
|
+
function extractError(err) {
|
|
1436
|
+
if (err instanceof Error) {
|
|
1437
|
+
const maybeStatus = err.status;
|
|
1438
|
+
return {
|
|
1439
|
+
...typeof maybeStatus === "number" ? { status: maybeStatus } : {},
|
|
1440
|
+
message: err.message
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
return { message: String(err) };
|
|
1444
|
+
}
|
|
1445
|
+
function topFailureReasons(results, n) {
|
|
1446
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1447
|
+
for (const r of results) {
|
|
1448
|
+
if (r.ok) continue;
|
|
1449
|
+
const key = `${r.error.status ?? "?"}::${r.error.message}`;
|
|
1450
|
+
const existing = counts.get(key);
|
|
1451
|
+
if (existing) {
|
|
1452
|
+
existing.count += 1;
|
|
1453
|
+
} else {
|
|
1454
|
+
counts.set(key, { ...r.error, count: 1 });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return Array.from(counts.values()).sort((a, b) => b.count - a.count).slice(0, n);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
907
1460
|
// src/capsule/idempotent.ts
|
|
908
1461
|
var isCapsule404 = (err) => err instanceof CapsuleApiError && err.status === 404;
|
|
909
1462
|
var isCapsuleTagNotFound = (err) => err instanceof CapsuleApiError && err.status === 422 && /tag not found/i.test(err.message);
|
|
@@ -979,8 +1532,8 @@ function validateWebsiteAddress(data, ctx) {
|
|
|
979
1532
|
return;
|
|
980
1533
|
}
|
|
981
1534
|
const parsed = new URL(data.address);
|
|
982
|
-
const
|
|
983
|
-
if (
|
|
1535
|
+
const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
1536
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
984
1537
|
ctx.addIssue({
|
|
985
1538
|
code: "custom",
|
|
986
1539
|
path: ["address"],
|
|
@@ -1042,14 +1595,26 @@ async function getParty(input) {
|
|
|
1042
1595
|
return data;
|
|
1043
1596
|
}
|
|
1044
1597
|
var getPartiesSchema = z4.object({
|
|
1045
|
-
ids: z4.array(z4.number().int().positive()).min(1).max(
|
|
1598
|
+
ids: z4.array(z4.number().int().positive()).min(1).max(50).describe(
|
|
1599
|
+
"Array of party IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel. Result shape is identical regardless of input size."
|
|
1600
|
+
),
|
|
1046
1601
|
embed: z4.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1047
1602
|
});
|
|
1048
1603
|
async function getParties(input) {
|
|
1049
|
-
const {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1604
|
+
const { ids, embed } = input;
|
|
1605
|
+
if (ids.length <= 10) {
|
|
1606
|
+
const { data } = await capsuleGet(`/parties/${ids.join(",")}`, {
|
|
1607
|
+
embed
|
|
1608
|
+
});
|
|
1609
|
+
return data;
|
|
1610
|
+
}
|
|
1611
|
+
const chunks = chunk(ids, 10);
|
|
1612
|
+
const responses = await Promise.all(
|
|
1613
|
+
chunks.map(
|
|
1614
|
+
(chunkIds) => capsuleGet(`/parties/${chunkIds.join(",")}`, { embed })
|
|
1615
|
+
)
|
|
1616
|
+
);
|
|
1617
|
+
return { parties: responses.flatMap((r) => r.data.parties) };
|
|
1053
1618
|
}
|
|
1054
1619
|
var listPartyOpportunitiesSchema = z4.object({
|
|
1055
1620
|
partyId: z4.number().int().positive(),
|
|
@@ -1133,6 +1698,14 @@ async function updateParty(input) {
|
|
|
1133
1698
|
if (mappedFields !== void 0) body["fields"] = mappedFields;
|
|
1134
1699
|
return capsulePut(`/parties/${id}`, { party: body });
|
|
1135
1700
|
}
|
|
1701
|
+
var batchUpdatePartySchema = z4.object({
|
|
1702
|
+
items: z4.array(updatePartySchema).min(1).max(50).describe(
|
|
1703
|
+
"Array of 1\u201350 update_party inputs. Each item is the same shape as a single update_party call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget (~4000 req/h)."
|
|
1704
|
+
)
|
|
1705
|
+
});
|
|
1706
|
+
async function batchUpdateParty(input, opts = {}) {
|
|
1707
|
+
return batchExecute("batch_update_party", input.items, (item) => updateParty(item), opts);
|
|
1708
|
+
}
|
|
1136
1709
|
var deletePartySchema = z4.object({
|
|
1137
1710
|
id: z4.number().int().positive(),
|
|
1138
1711
|
confirm: confirmFlag().describe(
|
|
@@ -1335,15 +1908,29 @@ async function getOpportunity(input) {
|
|
|
1335
1908
|
return data;
|
|
1336
1909
|
}
|
|
1337
1910
|
var getOpportunitiesSchema = z5.object({
|
|
1338
|
-
ids: z5.array(z5.number().int().positive()).min(1).max(
|
|
1911
|
+
ids: z5.array(z5.number().int().positive()).min(1).max(50).describe(
|
|
1912
|
+
"Array of opportunity IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
|
|
1913
|
+
),
|
|
1339
1914
|
embed: z5.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1340
1915
|
});
|
|
1341
1916
|
async function getOpportunities(input) {
|
|
1342
|
-
const {
|
|
1343
|
-
|
|
1344
|
-
{
|
|
1917
|
+
const { ids, embed } = input;
|
|
1918
|
+
if (ids.length <= 10) {
|
|
1919
|
+
const { data } = await capsuleGet(
|
|
1920
|
+
`/opportunities/${ids.join(",")}`,
|
|
1921
|
+
{ embed }
|
|
1922
|
+
);
|
|
1923
|
+
return data;
|
|
1924
|
+
}
|
|
1925
|
+
const chunks = chunk(ids, 10);
|
|
1926
|
+
const responses = await Promise.all(
|
|
1927
|
+
chunks.map(
|
|
1928
|
+
(chunkIds) => capsuleGet(`/opportunities/${chunkIds.join(",")}`, {
|
|
1929
|
+
embed
|
|
1930
|
+
})
|
|
1931
|
+
)
|
|
1345
1932
|
);
|
|
1346
|
-
return data;
|
|
1933
|
+
return { opportunities: responses.flatMap((r) => r.data.opportunities) };
|
|
1347
1934
|
}
|
|
1348
1935
|
var createOpportunitySchema = z5.object({
|
|
1349
1936
|
name: z5.string().min(1),
|
|
@@ -1404,6 +1991,19 @@ async function updateOpportunity(input) {
|
|
|
1404
1991
|
opportunity: body
|
|
1405
1992
|
});
|
|
1406
1993
|
}
|
|
1994
|
+
var batchUpdateOpportunitySchema = z5.object({
|
|
1995
|
+
items: z5.array(updateOpportunitySchema).min(1).max(50).describe(
|
|
1996
|
+
"Array of 1\u201350 update_opportunity inputs. Each item is the same shape as a single update_opportunity call \u2014 id is required, every other field is optional. Capped at 50 so a single tool call can't burn an outsized share of Capsule's hourly per-token rate budget."
|
|
1997
|
+
)
|
|
1998
|
+
});
|
|
1999
|
+
async function batchUpdateOpportunity(input, opts = {}) {
|
|
2000
|
+
return batchExecute(
|
|
2001
|
+
"batch_update_opportunity",
|
|
2002
|
+
input.items,
|
|
2003
|
+
(item) => updateOpportunity(item),
|
|
2004
|
+
opts
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
1407
2007
|
var deleteOpportunitySchema = z5.object({
|
|
1408
2008
|
id: z5.number().int().positive(),
|
|
1409
2009
|
confirm: confirmFlag().describe(
|
|
@@ -1449,14 +2049,26 @@ async function getProject(input) {
|
|
|
1449
2049
|
return data;
|
|
1450
2050
|
}
|
|
1451
2051
|
var getProjectsSchema = z6.object({
|
|
1452
|
-
ids: z6.array(z6.number().int().positive()).min(1).max(
|
|
2052
|
+
ids: z6.array(z6.number().int().positive()).min(1).max(50).describe(
|
|
2053
|
+
"Array of project IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
|
|
2054
|
+
),
|
|
1453
2055
|
embed: z6.string().optional().describe(EMBED_TAGS_FIELDS_DESCRIPTION)
|
|
1454
2056
|
});
|
|
1455
2057
|
async function getProjects(input) {
|
|
1456
|
-
const {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2058
|
+
const { ids, embed } = input;
|
|
2059
|
+
if (ids.length <= 10) {
|
|
2060
|
+
const { data } = await capsuleGet(`/kases/${ids.join(",")}`, {
|
|
2061
|
+
embed
|
|
2062
|
+
});
|
|
2063
|
+
return data;
|
|
2064
|
+
}
|
|
2065
|
+
const chunks = chunk(ids, 10);
|
|
2066
|
+
const responses = await Promise.all(
|
|
2067
|
+
chunks.map(
|
|
2068
|
+
(chunkIds) => capsuleGet(`/kases/${chunkIds.join(",")}`, { embed })
|
|
2069
|
+
)
|
|
2070
|
+
);
|
|
2071
|
+
return { kases: responses.flatMap((r) => r.data.kases) };
|
|
1460
2072
|
}
|
|
1461
2073
|
var createProjectSchema = z6.object({
|
|
1462
2074
|
name: z6.string().min(1),
|
|
@@ -1584,11 +2196,21 @@ async function getTask(input) {
|
|
|
1584
2196
|
return data;
|
|
1585
2197
|
}
|
|
1586
2198
|
var getTasksSchema = z7.object({
|
|
1587
|
-
ids: z7.array(z7.number().int().positive()).min(1).max(
|
|
2199
|
+
ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
|
|
2200
|
+
"Array of task IDs (1\u201350). Capsule's native batch-fetch endpoint caps at 10 per request; the connector transparently splits larger sets into 10-id chunks and fans out the Capsule calls in parallel."
|
|
2201
|
+
)
|
|
1588
2202
|
});
|
|
1589
2203
|
async function getTasks(input) {
|
|
1590
|
-
const {
|
|
1591
|
-
|
|
2204
|
+
const { ids } = input;
|
|
2205
|
+
if (ids.length <= 10) {
|
|
2206
|
+
const { data } = await capsuleGet(`/tasks/${ids.join(",")}`);
|
|
2207
|
+
return data;
|
|
2208
|
+
}
|
|
2209
|
+
const chunks = chunk(ids, 10);
|
|
2210
|
+
const responses = await Promise.all(
|
|
2211
|
+
chunks.map((chunkIds) => capsuleGet(`/tasks/${chunkIds.join(",")}`))
|
|
2212
|
+
);
|
|
2213
|
+
return { tasks: responses.flatMap((r) => r.data.tasks) };
|
|
1592
2214
|
}
|
|
1593
2215
|
var createTaskSchema = z7.object({
|
|
1594
2216
|
description: z7.string().min(1),
|
|
@@ -1648,6 +2270,14 @@ async function completeTask(input) {
|
|
|
1648
2270
|
task: { status: "COMPLETED" }
|
|
1649
2271
|
});
|
|
1650
2272
|
}
|
|
2273
|
+
var batchCompleteTaskSchema = z7.object({
|
|
2274
|
+
ids: z7.array(z7.number().int().positive()).min(1).max(50).describe(
|
|
2275
|
+
"Array of 1\u201350 task ids to mark COMPLETED in parallel. Each id resolves to one PUT /tasks/{id}; failures (e.g. 404 for a deleted task) surface per-item in the result array, the rest still complete. Capped at 50."
|
|
2276
|
+
)
|
|
2277
|
+
});
|
|
2278
|
+
async function batchCompleteTask(input, opts = {}) {
|
|
2279
|
+
return batchExecute("batch_complete_task", input.ids, (id) => completeTask({ id }), opts);
|
|
2280
|
+
}
|
|
1651
2281
|
var deleteTaskSchema = z7.object({
|
|
1652
2282
|
id: z7.number().int().positive(),
|
|
1653
2283
|
confirm: confirmFlag().describe(
|
|
@@ -1794,7 +2424,7 @@ var paginationFields = {
|
|
|
1794
2424
|
};
|
|
1795
2425
|
var listPipelinesSchema = z9.object({ ...paginationFields });
|
|
1796
2426
|
async function listPipelines(input) {
|
|
1797
|
-
const { data, nextPage } = await
|
|
2427
|
+
const { data, nextPage } = await capsuleGetCached("/pipelines", {
|
|
1798
2428
|
page: input.page ?? 1,
|
|
1799
2429
|
perPage: input.perPage ?? 100
|
|
1800
2430
|
});
|
|
@@ -1805,7 +2435,7 @@ var listMilestonesSchema = z9.object({
|
|
|
1805
2435
|
...paginationFields
|
|
1806
2436
|
});
|
|
1807
2437
|
async function listMilestones(input) {
|
|
1808
|
-
const { data, nextPage } = await
|
|
2438
|
+
const { data, nextPage } = await capsuleGetCached(
|
|
1809
2439
|
`/pipelines/${input.pipelineId}/milestones`,
|
|
1810
2440
|
{ page: input.page ?? 1, perPage: input.perPage ?? 100 }
|
|
1811
2441
|
);
|
|
@@ -1820,7 +2450,7 @@ var paginationFields2 = {
|
|
|
1820
2450
|
};
|
|
1821
2451
|
var listBoardsSchema = z10.object({ ...paginationFields2 });
|
|
1822
2452
|
async function listBoards(input) {
|
|
1823
|
-
const { data, nextPage } = await
|
|
2453
|
+
const { data, nextPage } = await capsuleGetCached("/boards", {
|
|
1824
2454
|
page: input.page ?? 1,
|
|
1825
2455
|
perPage: input.perPage ?? 100
|
|
1826
2456
|
});
|
|
@@ -1834,7 +2464,7 @@ var listStagesSchema = z10.object({
|
|
|
1834
2464
|
});
|
|
1835
2465
|
async function listStages(input) {
|
|
1836
2466
|
const path = input.boardId !== void 0 ? `/boards/${input.boardId}/stages` : "/stages";
|
|
1837
|
-
const { data, nextPage } = await
|
|
2467
|
+
const { data, nextPage } = await capsuleGetCached(path, {
|
|
1838
2468
|
page: input.page ?? 1,
|
|
1839
2469
|
perPage: input.perPage ?? 100
|
|
1840
2470
|
});
|
|
@@ -1861,7 +2491,7 @@ var listTagsSchema = z11.object({
|
|
|
1861
2491
|
});
|
|
1862
2492
|
async function listTags(input) {
|
|
1863
2493
|
const path = TAG_LIST_PATH[input.entity];
|
|
1864
|
-
const { data, nextPage } = await
|
|
2494
|
+
const { data, nextPage } = await capsuleGetCached(path, {
|
|
1865
2495
|
page: input.page ?? 1,
|
|
1866
2496
|
perPage: input.perPage ?? 100
|
|
1867
2497
|
});
|
|
@@ -1877,9 +2507,11 @@ var addTagSchema = z11.object({
|
|
|
1877
2507
|
async function addTag(input) {
|
|
1878
2508
|
const { entity, entityId, tagName } = input;
|
|
1879
2509
|
const wrapper = ENTITY_TO_WRAPPER[entity];
|
|
1880
|
-
|
|
2510
|
+
const result = await capsulePut(`/${entity}/${entityId}`, {
|
|
1881
2511
|
[wrapper]: { tags: [{ name: tagName }] }
|
|
1882
2512
|
});
|
|
2513
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "add_tag");
|
|
2514
|
+
return result;
|
|
1883
2515
|
}
|
|
1884
2516
|
var removeTagByIdSchema = z11.object({
|
|
1885
2517
|
entity: TagEntity,
|
|
@@ -1891,17 +2523,17 @@ var removeTagByIdSchema = z11.object({
|
|
|
1891
2523
|
async function removeTagById(input) {
|
|
1892
2524
|
const { entity, entityId, tagId } = input;
|
|
1893
2525
|
const wrapper = ENTITY_TO_WRAPPER[entity];
|
|
1894
|
-
|
|
2526
|
+
const result = await idempotentWithResult(
|
|
1895
2527
|
() => capsulePut(`/${entity}/${entityId}`, {
|
|
1896
2528
|
[wrapper]: { tags: [{ id: tagId, _delete: true }] }
|
|
1897
2529
|
}),
|
|
1898
|
-
(
|
|
2530
|
+
(result2) => ({
|
|
1899
2531
|
removed: true,
|
|
1900
2532
|
alreadyRemoved: false,
|
|
1901
2533
|
entity,
|
|
1902
2534
|
entityId,
|
|
1903
2535
|
tagId,
|
|
1904
|
-
...
|
|
2536
|
+
...result2
|
|
1905
2537
|
}),
|
|
1906
2538
|
() => ({ removed: true, alreadyRemoved: true, entity, entityId, tagId }),
|
|
1907
2539
|
// Tag detach uses PUT with _delete: true and 422s with "tag not
|
|
@@ -1909,6 +2541,24 @@ async function removeTagById(input) {
|
|
|
1909
2541
|
// 404. Other 422s with different wording still surface.
|
|
1910
2542
|
isCapsuleTagNotFound
|
|
1911
2543
|
);
|
|
2544
|
+
invalidateByPrefix(TAG_LIST_PATH[entity], "remove_tag_by_id");
|
|
2545
|
+
return result;
|
|
2546
|
+
}
|
|
2547
|
+
var batchAddTagSchema = z11.object({
|
|
2548
|
+
items: z11.array(addTagSchema).min(1).max(50).describe(
|
|
2549
|
+
"Array of 1\u201350 add_tag inputs. Useful for mass-tagging \u2014 e.g. 'tag these 20 contacts as RSAC26'. Each item is the same shape as a single add_tag call. The list_tags cache is invalidated for each affected entity type. Capped at 50."
|
|
2550
|
+
)
|
|
2551
|
+
});
|
|
2552
|
+
async function batchAddTag(input, opts = {}) {
|
|
2553
|
+
return batchExecute("batch_add_tag", input.items, (item) => addTag(item), opts);
|
|
2554
|
+
}
|
|
2555
|
+
var batchRemoveTagByIdSchema = z11.object({
|
|
2556
|
+
items: z11.array(removeTagByIdSchema).min(1).max(50).describe(
|
|
2557
|
+
"Array of 1\u201350 remove_tag_by_id inputs. Each item is the same shape as a single remove_tag_by_id call. Detaches the tag from each specified entity; the tag definition itself persists in the tenant. Capped at 50."
|
|
2558
|
+
)
|
|
2559
|
+
});
|
|
2560
|
+
async function batchRemoveTagById(input, opts = {}) {
|
|
2561
|
+
return batchExecute("batch_remove_tag_by_id", input.items, (item) => removeTagById(item), opts);
|
|
1912
2562
|
}
|
|
1913
2563
|
|
|
1914
2564
|
// src/tools/users.ts
|
|
@@ -1918,7 +2568,7 @@ var listUsersSchema = z12.object({
|
|
|
1918
2568
|
perPage: z12.number().int().min(1).max(100).optional()
|
|
1919
2569
|
});
|
|
1920
2570
|
async function listUsers(input) {
|
|
1921
|
-
const { data, nextPage } = await
|
|
2571
|
+
const { data, nextPage } = await capsuleGetCached("/users", {
|
|
1922
2572
|
page: input.page ?? 1,
|
|
1923
2573
|
perPage: input.perPage ?? 100
|
|
1924
2574
|
});
|
|
@@ -1987,7 +2637,7 @@ var paginationFields3 = {
|
|
|
1987
2637
|
};
|
|
1988
2638
|
var listTeamsSchema = z14.object({ ...paginationFields3 });
|
|
1989
2639
|
async function listTeams(input) {
|
|
1990
|
-
const { data, nextPage } = await
|
|
2640
|
+
const { data, nextPage } = await capsuleGetCached("/teams", {
|
|
1991
2641
|
page: input.page ?? 1,
|
|
1992
2642
|
perPage: input.perPage ?? 100
|
|
1993
2643
|
});
|
|
@@ -1995,7 +2645,7 @@ async function listTeams(input) {
|
|
|
1995
2645
|
}
|
|
1996
2646
|
var listLostReasonsSchema = z14.object({ ...paginationFields3 });
|
|
1997
2647
|
async function listLostReasons(input) {
|
|
1998
|
-
const { data, nextPage } = await
|
|
2648
|
+
const { data, nextPage } = await capsuleGetCached("/lostreasons", {
|
|
1999
2649
|
page: input.page ?? 1,
|
|
2000
2650
|
perPage: input.perPage ?? 100
|
|
2001
2651
|
});
|
|
@@ -2003,20 +2653,23 @@ async function listLostReasons(input) {
|
|
|
2003
2653
|
}
|
|
2004
2654
|
var listActivityTypesSchema = z14.object({ ...paginationFields3 });
|
|
2005
2655
|
async function listActivityTypes(input) {
|
|
2006
|
-
const { data, nextPage } = await
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2656
|
+
const { data, nextPage } = await capsuleGetCached(
|
|
2657
|
+
"/activitytypes",
|
|
2658
|
+
{
|
|
2659
|
+
page: input.page ?? 1,
|
|
2660
|
+
perPage: input.perPage ?? 100
|
|
2661
|
+
}
|
|
2662
|
+
);
|
|
2010
2663
|
return { ...data, nextPage };
|
|
2011
2664
|
}
|
|
2012
2665
|
var getSiteSchema = z14.object({});
|
|
2013
2666
|
async function getSite(_input) {
|
|
2014
|
-
const { data } = await
|
|
2667
|
+
const { data } = await capsuleGetCached("/site");
|
|
2015
2668
|
return data;
|
|
2016
2669
|
}
|
|
2017
2670
|
var listTrackDefinitionsSchema = z14.object({ ...paginationFields3 });
|
|
2018
2671
|
async function listTrackDefinitions(input) {
|
|
2019
|
-
const { data, nextPage } = await
|
|
2672
|
+
const { data, nextPage } = await capsuleGetCached(
|
|
2020
2673
|
"/trackdefinitions",
|
|
2021
2674
|
{ page: input.page ?? 1, perPage: input.perPage ?? 100 }
|
|
2022
2675
|
);
|
|
@@ -2024,7 +2677,7 @@ async function listTrackDefinitions(input) {
|
|
|
2024
2677
|
}
|
|
2025
2678
|
var listCategoriesSchema = z14.object({ ...paginationFields3 });
|
|
2026
2679
|
async function listCategories(input) {
|
|
2027
|
-
const { data, nextPage } = await
|
|
2680
|
+
const { data, nextPage } = await capsuleGetCached("/categories", {
|
|
2028
2681
|
page: input.page ?? 1,
|
|
2029
2682
|
perPage: input.perPage ?? 100
|
|
2030
2683
|
});
|
|
@@ -2032,7 +2685,7 @@ async function listCategories(input) {
|
|
|
2032
2685
|
}
|
|
2033
2686
|
var listGoalsSchema = z14.object({ ...paginationFields3 });
|
|
2034
2687
|
async function listGoals(input) {
|
|
2035
|
-
const { data, nextPage } = await
|
|
2688
|
+
const { data, nextPage } = await capsuleGetCached("/goals", {
|
|
2036
2689
|
page: input.page ?? 1,
|
|
2037
2690
|
perPage: input.perPage ?? 100
|
|
2038
2691
|
});
|
|
@@ -2191,7 +2844,7 @@ var listCustomFieldsSchema = z17.object({
|
|
|
2191
2844
|
entity: CustomFieldEntity
|
|
2192
2845
|
});
|
|
2193
2846
|
async function listCustomFields(input) {
|
|
2194
|
-
const { data } = await
|
|
2847
|
+
const { data } = await capsuleGetCached(
|
|
2195
2848
|
`/${input.entity}/fields/definitions`
|
|
2196
2849
|
);
|
|
2197
2850
|
return data;
|
|
@@ -2201,7 +2854,7 @@ var getCustomFieldSchema = z17.object({
|
|
|
2201
2854
|
fieldId: z17.number().int().positive().describe("Custom field definition id.")
|
|
2202
2855
|
});
|
|
2203
2856
|
async function getCustomField(input) {
|
|
2204
|
-
const { data } = await
|
|
2857
|
+
const { data } = await capsuleGetCached(
|
|
2205
2858
|
`/${input.entity}/fields/definitions/${input.fieldId}`
|
|
2206
2859
|
);
|
|
2207
2860
|
return data;
|
|
@@ -2372,7 +3025,7 @@ var listSavedFiltersSchema = z20.object({
|
|
|
2372
3025
|
entity: EntitySchema
|
|
2373
3026
|
});
|
|
2374
3027
|
async function listSavedFilters(input) {
|
|
2375
|
-
const { data } = await
|
|
3028
|
+
const { data } = await capsuleGetCached(`/${input.entity}/filters`);
|
|
2376
3029
|
return data;
|
|
2377
3030
|
}
|
|
2378
3031
|
var runSavedFilterSchema = z20.object({
|
|
@@ -2391,15 +3044,35 @@ async function runSavedFilter(input) {
|
|
|
2391
3044
|
}
|
|
2392
3045
|
|
|
2393
3046
|
// src/server.ts
|
|
2394
|
-
function createCapsuleMcpServer() {
|
|
3047
|
+
function createCapsuleMcpServer(opts) {
|
|
2395
3048
|
const readOnly = isReadOnly();
|
|
2396
|
-
const
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
3049
|
+
const tasksCfg = getTasksConfig();
|
|
3050
|
+
const tasksWired = tasksCfg.enabled && !!opts?.clientId;
|
|
3051
|
+
const server = new McpServer(
|
|
3052
|
+
{
|
|
3053
|
+
name: "capsulemcp",
|
|
3054
|
+
version: "1.6.0",
|
|
3055
|
+
description: "Read and (optionally) modify Capsule CRM data \u2014 parties, opportunities, projects, tasks, timeline entries, pipelines, tags.",
|
|
3056
|
+
websiteUrl: "https://github.com/soil-dev/capsulemcp",
|
|
3057
|
+
icons: ICONS
|
|
3058
|
+
},
|
|
3059
|
+
tasksWired ? {
|
|
3060
|
+
// tasksWired guards clientId presence; narrow explicitly
|
|
3061
|
+
// for the type-checker rather than using `!`.
|
|
3062
|
+
taskStore: createScopedTaskStore(opts?.clientId ?? ""),
|
|
3063
|
+
capabilities: {
|
|
3064
|
+
tasks: {
|
|
3065
|
+
// The SDK's task capability schema uses {} for "present"
|
|
3066
|
+
// markers, not booleans — see ServerTasksCapabilitySchema
|
|
3067
|
+
// in @modelcontextprotocol/sdk types.ts.
|
|
3068
|
+
list: {},
|
|
3069
|
+
cancel: {},
|
|
3070
|
+
requests: { tools: { call: {} } }
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
} : void 0
|
|
3074
|
+
);
|
|
3075
|
+
const registerBatchTool = tasksWired ? registerToolTask : (s, name, description, schema, handler) => registerTool(s, name, description, schema, (input) => handler(input, {}));
|
|
2403
3076
|
registerTool(
|
|
2404
3077
|
server,
|
|
2405
3078
|
"search_parties",
|
|
@@ -2417,28 +3090,28 @@ function createCapsuleMcpServer() {
|
|
|
2417
3090
|
registerTool(
|
|
2418
3091
|
server,
|
|
2419
3092
|
"get_party",
|
|
2420
|
-
"Fetch a single party (person or organisation) by its numeric
|
|
3093
|
+
"Fetch a single party (person or organisation) by its numeric id. Returns the full record including type, name fields, emails, phones, addresses, websites, and any embedded tags or custom fields. Use embed='tags,fields' to include those in one round-trip. For batch fetches of up to 50 parties at once, use get_parties instead.",
|
|
2421
3094
|
getPartySchema,
|
|
2422
3095
|
getParty
|
|
2423
3096
|
);
|
|
2424
3097
|
registerTool(
|
|
2425
3098
|
server,
|
|
2426
3099
|
"get_parties",
|
|
2427
|
-
"Batch-fetch up to
|
|
3100
|
+
"Batch-fetch up to 50 parties by ID. For 1\u201310 ids this is a single Capsule round trip (native multi-id endpoint); for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged. Use this whenever Claude has several party IDs to avoid N sequential round trips of get_party.",
|
|
2428
3101
|
getPartiesSchema,
|
|
2429
3102
|
getParties
|
|
2430
3103
|
);
|
|
2431
3104
|
registerTool(
|
|
2432
3105
|
server,
|
|
2433
3106
|
"list_party_opportunities",
|
|
2434
|
-
"List
|
|
3107
|
+
"List opportunities linked to a given party. Returns the same record shape as get_opportunity, filtered to one party \u2014 use this to answer 'what deals do we have with X?' without enumerating all opportunities. Accepts optional embed (e.g. 'tags,fields') to include those in one round-trip.",
|
|
2435
3108
|
listPartyOpportunitiesSchema,
|
|
2436
3109
|
listPartyOpportunities
|
|
2437
3110
|
);
|
|
2438
3111
|
registerTool(
|
|
2439
3112
|
server,
|
|
2440
3113
|
"list_party_projects",
|
|
2441
|
-
"List
|
|
3114
|
+
"List projects (cases) linked to a given party. Returns the same record shape as get_project, filtered to one party \u2014 use this to answer 'what cases is X involved in?' without enumerating all projects. Accepts optional embed (e.g. 'tags,fields'). For the opportunity-side analogue, use list_party_opportunities.",
|
|
2442
3115
|
listPartyProjectsSchema,
|
|
2443
3116
|
listPartyProjects
|
|
2444
3117
|
);
|
|
@@ -2485,6 +3158,13 @@ function createCapsuleMcpServer() {
|
|
|
2485
3158
|
updatePartySchema,
|
|
2486
3159
|
updateParty
|
|
2487
3160
|
);
|
|
3161
|
+
registerBatchTool(
|
|
3162
|
+
server,
|
|
3163
|
+
"batch_update_party",
|
|
3164
|
+
"Update 1\u201350 parties in parallel. Same input shape as update_party but wrapped in an `items` array. Use this \u2014 not N sequential update_party calls \u2014 for any homogeneous multi-record write (mass owner reassignment, bulk metadata corrections, etc.). Capsule has no batch-write API, so the connector fans out parallel HTTP requests with a default concurrency cap of 5 (configurable via CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures are possible \u2014 Capsule has no rollback, so successful items stay applied even if other items 4xx. Read the per-item result array to know which ones need follow-up.",
|
|
3165
|
+
batchUpdatePartySchema,
|
|
3166
|
+
batchUpdateParty
|
|
3167
|
+
);
|
|
2488
3168
|
registerTool(
|
|
2489
3169
|
server,
|
|
2490
3170
|
"delete_party",
|
|
@@ -2566,14 +3246,14 @@ function createCapsuleMcpServer() {
|
|
|
2566
3246
|
registerTool(
|
|
2567
3247
|
server,
|
|
2568
3248
|
"get_opportunity",
|
|
2569
|
-
"Fetch a single opportunity by its numeric
|
|
3249
|
+
"Fetch a single opportunity by its numeric id. Returns the full record including value, milestone, owner, party, and any embedded tags/custom fields. Use embed='tags,fields' to include those in one round-trip. For batch fetches of up to 50 opportunities at once, use get_opportunities instead.",
|
|
2570
3250
|
getOpportunitySchema,
|
|
2571
3251
|
getOpportunity
|
|
2572
3252
|
);
|
|
2573
3253
|
registerTool(
|
|
2574
3254
|
server,
|
|
2575
3255
|
"get_opportunities",
|
|
2576
|
-
"Batch-fetch up to
|
|
3256
|
+
"Batch-fetch up to 50 opportunities by id. For 1\u201310 ids this is a single Capsule round trip (native multi-id endpoint); for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged. Returns each opportunity's full record (value, milestone, owner, party). For a single id, use get_opportunity instead.",
|
|
2577
3257
|
getOpportunitiesSchema,
|
|
2578
3258
|
getOpportunities
|
|
2579
3259
|
);
|
|
@@ -2594,7 +3274,7 @@ function createCapsuleMcpServer() {
|
|
|
2594
3274
|
registerTool(
|
|
2595
3275
|
server,
|
|
2596
3276
|
"list_associated_projects",
|
|
2597
|
-
"List projects (cases) associated with a given opportunity. The inverse direction (project \u2192 opportunity) is on each project's `opportunity` field directly.",
|
|
3277
|
+
"List projects (cases) associated with a given opportunity. Returns the same record shape as list_projects, filtered to one opportunity. The inverse direction (project \u2192 opportunity) is on each project's `opportunity` field directly, so this tool is only needed for opportunity \u2192 projects discovery \u2014 use list_party_projects for party \u2192 projects.",
|
|
2598
3278
|
listAssociatedProjectsSchema,
|
|
2599
3279
|
listAssociatedProjects
|
|
2600
3280
|
);
|
|
@@ -2602,7 +3282,7 @@ function createCapsuleMcpServer() {
|
|
|
2602
3282
|
registerTool(
|
|
2603
3283
|
server,
|
|
2604
3284
|
"create_opportunity",
|
|
2605
|
-
"Create a new opportunity linked to a party and a pipeline milestone.",
|
|
3285
|
+
"Create a new opportunity linked to a party. Requires partyId and milestoneId (which pins the deal to a specific pipeline stage \u2014 pipeline is inferred from the milestone). Value is optional but if amount is set, currency must be set too (3-letter ISO 4217 code, e.g. 'USD'). Discover valid milestone ids via list_pipelines + list_milestones first. For multi-party deals, use add_additional_party after creation.",
|
|
2606
3286
|
createOpportunitySchema,
|
|
2607
3287
|
createOpportunity
|
|
2608
3288
|
);
|
|
@@ -2613,6 +3293,13 @@ function createCapsuleMcpServer() {
|
|
|
2613
3293
|
updateOpportunitySchema,
|
|
2614
3294
|
updateOpportunity
|
|
2615
3295
|
);
|
|
3296
|
+
registerBatchTool(
|
|
3297
|
+
server,
|
|
3298
|
+
"batch_update_opportunity",
|
|
3299
|
+
"Update 1\u201350 opportunities in parallel. Same input shape as update_opportunity but wrapped in an `items` array. Use this \u2014 not N sequential update_opportunity calls \u2014 for mass stage transitions (e.g. move a milestone batch to Won), owner reassignments, or value adjustments. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. Partial failures possible; Capsule has no rollback.",
|
|
3300
|
+
batchUpdateOpportunitySchema,
|
|
3301
|
+
batchUpdateOpportunity
|
|
3302
|
+
);
|
|
2616
3303
|
registerTool(
|
|
2617
3304
|
server,
|
|
2618
3305
|
"delete_opportunity",
|
|
@@ -2638,14 +3325,14 @@ function createCapsuleMcpServer() {
|
|
|
2638
3325
|
registerTool(
|
|
2639
3326
|
server,
|
|
2640
3327
|
"get_project",
|
|
2641
|
-
"Fetch a single project (case) by its numeric
|
|
3328
|
+
"Fetch a single project (Capsule's term: 'case') by its numeric id. Returns the full record including name, description, status (OPEN/CLOSED), owner, stage, board, opportunityId (if linked), and timestamps. Use embed='tags,fields' to include attached tags and custom field values in one round-trip. For batch fetches of up to 50 projects at once, use get_projects instead. For the project's timeline (notes, captured emails, completed-task records) use list_project_entries.",
|
|
2642
3329
|
getProjectSchema,
|
|
2643
3330
|
getProject
|
|
2644
3331
|
);
|
|
2645
3332
|
registerTool(
|
|
2646
3333
|
server,
|
|
2647
3334
|
"get_projects",
|
|
2648
|
-
"Batch-fetch up to
|
|
3335
|
+
"Batch-fetch up to 50 projects (cases) by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
|
|
2649
3336
|
getProjectsSchema,
|
|
2650
3337
|
getProjects
|
|
2651
3338
|
);
|
|
@@ -2660,7 +3347,7 @@ function createCapsuleMcpServer() {
|
|
|
2660
3347
|
registerTool(
|
|
2661
3348
|
server,
|
|
2662
3349
|
"create_project",
|
|
2663
|
-
"Create a new project (case) in Capsule CRM linked to a party.",
|
|
3350
|
+
"Create a new project (case) in Capsule CRM linked to a party. Requires partyId and name; description, status, owner, and starting board/stage are optional. To pin a project to a specific board+stage on creation, pass stageId (which uniquely identifies a stage within a board). Discover valid ids via list_boards + list_stages. Returns the created project including its assigned id.",
|
|
2664
3351
|
createProjectSchema,
|
|
2665
3352
|
createProject
|
|
2666
3353
|
);
|
|
@@ -2724,14 +3411,14 @@ function createCapsuleMcpServer() {
|
|
|
2724
3411
|
registerTool(
|
|
2725
3412
|
server,
|
|
2726
3413
|
"get_task",
|
|
2727
|
-
"Fetch a single task by its numeric
|
|
3414
|
+
"Fetch a single task by its numeric id. Returns the task's description, due date, owner, completion state, and the entity it's attached to (party / opportunity / project, if any \u2014 standalone tasks not tied to a record are also valid). For batch fetches of up to 50 tasks at once, use get_tasks instead.",
|
|
2728
3415
|
getTaskSchema,
|
|
2729
3416
|
getTask
|
|
2730
3417
|
);
|
|
2731
3418
|
registerTool(
|
|
2732
3419
|
server,
|
|
2733
3420
|
"get_tasks",
|
|
2734
|
-
"Batch-fetch up to
|
|
3421
|
+
"Batch-fetch up to 50 tasks by ID. For 1\u201310 ids this is a single Capsule round trip; for 11\u201350 ids the connector transparently splits into 10-id chunks and fans out parallel Capsule requests, so the caller sees a single tool call with all results merged.",
|
|
2735
3422
|
getTasksSchema,
|
|
2736
3423
|
getTasks
|
|
2737
3424
|
);
|
|
@@ -2746,7 +3433,7 @@ function createCapsuleMcpServer() {
|
|
|
2746
3433
|
registerTool(
|
|
2747
3434
|
server,
|
|
2748
3435
|
"update_task",
|
|
2749
|
-
"Update fields on an existing task
|
|
3436
|
+
"Update fields on an existing task: `description`, `dueOn`, `dueTime`, `detail`, `status` (OPEN or COMPLETED), and `ownerId`. Only the fields you provide are changed. To mark a task done, prefer the dedicated `complete_task` tool \u2014 it's idempotent (a no-op success on an already-completed task) and semantically clearer than `update_task status=COMPLETED`. Capsule rejects directly setting status=PENDING (which exists only internally for track-driven tasks); use OPEN or COMPLETED. Completed tasks remain fully editable \u2014 Capsule does not enforce closed-record immutability.",
|
|
2750
3437
|
updateTaskSchema,
|
|
2751
3438
|
updateTask
|
|
2752
3439
|
);
|
|
@@ -2757,6 +3444,13 @@ function createCapsuleMcpServer() {
|
|
|
2757
3444
|
completeTaskSchema,
|
|
2758
3445
|
completeTask
|
|
2759
3446
|
);
|
|
3447
|
+
registerBatchTool(
|
|
3448
|
+
server,
|
|
3449
|
+
"batch_complete_task",
|
|
3450
|
+
"Mark 1\u201350 tasks COMPLETED in parallel. Pass `ids: [task_id, \u2026]`. Natural for end-of-week catchups, 'close all the follow-ups from this campaign', etc. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per id], summary: {total, succeeded, failed} }. A task that's already completed or deleted shows up as a per-item failure with the Capsule status; the rest still complete.",
|
|
3451
|
+
batchCompleteTaskSchema,
|
|
3452
|
+
batchCompleteTask
|
|
3453
|
+
);
|
|
2760
3454
|
registerTool(
|
|
2761
3455
|
server,
|
|
2762
3456
|
"delete_task",
|
|
@@ -2768,28 +3462,28 @@ function createCapsuleMcpServer() {
|
|
|
2768
3462
|
registerTool(
|
|
2769
3463
|
server,
|
|
2770
3464
|
"list_party_entries",
|
|
2771
|
-
"List timeline entries (notes, captured emails, completed-task records) for a party. Use this to read the conversation history with a contact or organisation.",
|
|
3465
|
+
"List timeline entries (notes, captured emails, completed-task records) for a party. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to read the conversation history with a contact or organisation \u2014 answers questions like 'what's the latest with X?' For opportunity or project timelines, use list_opportunity_entries or list_project_entries respectively.",
|
|
2772
3466
|
listPartyEntriesSchema,
|
|
2773
3467
|
listPartyEntries
|
|
2774
3468
|
);
|
|
2775
3469
|
registerTool(
|
|
2776
3470
|
server,
|
|
2777
3471
|
"list_opportunity_entries",
|
|
2778
|
-
"List timeline entries (notes, captured emails, completed-task records) for an opportunity.",
|
|
3472
|
+
"List timeline entries (notes, captured emails, completed-task records) for an opportunity. Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to answer 'what's the latest on deal X?' For party or project timelines, use list_party_entries or list_project_entries respectively.",
|
|
2779
3473
|
listOpportunityEntriesSchema,
|
|
2780
3474
|
listOpportunityEntries
|
|
2781
3475
|
);
|
|
2782
3476
|
registerTool(
|
|
2783
3477
|
server,
|
|
2784
3478
|
"list_project_entries",
|
|
2785
|
-
"List timeline entries (notes, captured emails, completed-task records) for a project (case).",
|
|
3479
|
+
"List timeline entries (notes, captured emails, completed-task records) for a project (case). Returns entries newest-first. Each entry has a type ('note', 'email', 'task'), free-text content, and timestamps. Use this to answer 'what's the latest on case X?' For party or opportunity timelines, use list_party_entries or list_opportunity_entries respectively.",
|
|
2786
3480
|
listProjectEntriesSchema,
|
|
2787
3481
|
listProjectEntries
|
|
2788
3482
|
);
|
|
2789
3483
|
registerTool(
|
|
2790
3484
|
server,
|
|
2791
3485
|
"get_entry",
|
|
2792
|
-
"Fetch a single timeline entry by its numeric
|
|
3486
|
+
"Fetch a single timeline entry by its numeric id. Returns the full payload \u2014 for a note: the body text; for a captured email: subject, body, from/to, and timestamps; for a completed-task record: the original task fields. Useful when you have an entry id from one of the `list_*_entries` calls and want the full content. To modify the body or activity-type of an existing entry use `update_entry`; to delete one use `delete_entry`.",
|
|
2793
3487
|
getEntrySchema,
|
|
2794
3488
|
getEntry
|
|
2795
3489
|
);
|
|
@@ -2804,6 +3498,10 @@ function createCapsuleMcpServer() {
|
|
|
2804
3498
|
"get_attachment",
|
|
2805
3499
|
"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.",
|
|
2806
3500
|
getAttachmentSchema.shape,
|
|
3501
|
+
// get_attachment is read-only — downloads a binary, never mutates.
|
|
3502
|
+
// Mirrors the auto-inferred `readOnlyHint: true` that
|
|
3503
|
+
// `registerTool` applies to every other `get_*` tool.
|
|
3504
|
+
{ readOnlyHint: true },
|
|
2807
3505
|
async (input) => {
|
|
2808
3506
|
const result = await getAttachment(input);
|
|
2809
3507
|
if (result.truncated) {
|
|
@@ -2910,28 +3608,28 @@ function createCapsuleMcpServer() {
|
|
|
2910
3608
|
registerTool(
|
|
2911
3609
|
server,
|
|
2912
3610
|
"list_pipelines",
|
|
2913
|
-
"List all sales pipelines defined in Capsule CRM.",
|
|
3611
|
+
"List all sales pipelines defined in Capsule CRM. Returns each pipeline's id, name, and milestones (deal stages, ordered by position). Use this to discover the pipelineId when creating an opportunity, then pick a milestone from the same pipeline via list_milestones. Pipelines are stable per Capsule account \u2014 list once and cache; they rarely change.",
|
|
2914
3612
|
listPipelinesSchema,
|
|
2915
3613
|
listPipelines
|
|
2916
3614
|
);
|
|
2917
3615
|
registerTool(
|
|
2918
3616
|
server,
|
|
2919
3617
|
"list_milestones",
|
|
2920
|
-
"List
|
|
3618
|
+
"List milestones (deal stages) within a specific opportunity pipeline. Returns each milestone's id, name, probability, and position. Used when creating opportunities (pass milestoneId to create_opportunity) or moving them across stages (set milestoneId in update_opportunity). Discover the pipelineId first via list_pipelines. Milestones are pipeline-scoped \u2014 not interchangeable across pipelines.",
|
|
2921
3619
|
listMilestonesSchema,
|
|
2922
3620
|
listMilestones
|
|
2923
3621
|
);
|
|
2924
3622
|
registerTool(
|
|
2925
3623
|
server,
|
|
2926
3624
|
"list_boards",
|
|
2927
|
-
"List all project (
|
|
3625
|
+
"List all project (case) boards defined in Capsule. A board is a grouping of stages that projects flow through \u2014 the project equivalent of an opportunity pipeline. Returns each board's id, name, and stages. Use this to discover boardId when creating a project, then pick a starting stage via list_stages. Like pipelines, boards are stable per account.",
|
|
2928
3626
|
listBoardsSchema,
|
|
2929
3627
|
listBoards
|
|
2930
3628
|
);
|
|
2931
3629
|
registerTool(
|
|
2932
3630
|
server,
|
|
2933
3631
|
"list_stages",
|
|
2934
|
-
"List project stages. Without arguments returns every stage across every board (each carries a
|
|
3632
|
+
"List project (case) stages. Without arguments returns every stage across every board (each entry carries a `.board` reference so you can tell them apart). Pass `boardId` to scope the result to one specific board's stages. Use this to discover the numeric `stage.id` that `create_project` / `update_project` consume \u2014 stage names alone won't do, Capsule resolves by id. For opportunity (deal) stages, use `list_pipelines` instead \u2014 opportunities don't have stages in the project sense.",
|
|
2935
3633
|
listStagesSchema,
|
|
2936
3634
|
listStages
|
|
2937
3635
|
);
|
|
@@ -2945,21 +3643,21 @@ function createCapsuleMcpServer() {
|
|
|
2945
3643
|
registerTool(
|
|
2946
3644
|
server,
|
|
2947
3645
|
"list_lostreasons",
|
|
2948
|
-
"List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor'). Useful for analysing closed-lost opportunities by reason.",
|
|
3646
|
+
"List all configured opportunity-loss reasons (e.g. 'Poor Qualification', 'Lost to competitor', 'Price too high'). Returns each reason's id and name; the set is account-configured rather than a fixed enum, so call this to discover valid ids before referencing a lostReason in update_opportunity when closing a deal as lost. Useful for analysing closed-lost opportunities by reason.",
|
|
2949
3647
|
listLostReasonsSchema,
|
|
2950
3648
|
listLostReasons
|
|
2951
3649
|
);
|
|
2952
3650
|
registerTool(
|
|
2953
3651
|
server,
|
|
2954
3652
|
"list_activitytypes",
|
|
2955
|
-
"List all configured activity types (e.g. Call, Meeting, Email). These are the categories used when logging timeline entries.",
|
|
3653
|
+
"List all configured activity types (e.g. Call, Meeting, Email). These are the categories used when logging timeline entries via add_note. Returns each type's id and name. The set is account-configured rather than a fixed enum, so call this to discover valid values before referencing an activityType in entry creation.",
|
|
2956
3654
|
listActivityTypesSchema,
|
|
2957
3655
|
listActivityTypes
|
|
2958
3656
|
);
|
|
2959
3657
|
registerTool(
|
|
2960
3658
|
server,
|
|
2961
3659
|
"list_categories",
|
|
2962
|
-
"List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Used to label and filter timeline entries and tasks.",
|
|
3660
|
+
"List configured entry/task categories (Call, Email, Meeting, Follow-up, etc.) with their colours. Returns each category's id, name, and colour. The set is account-configured rather than a fixed enum \u2014 call this to discover valid category ids before referencing one in add_note or create_task. Used to label and filter timeline entries and tasks.",
|
|
2963
3661
|
listCategoriesSchema,
|
|
2964
3662
|
listCategories
|
|
2965
3663
|
);
|
|
@@ -3015,7 +3713,7 @@ function createCapsuleMcpServer() {
|
|
|
3015
3713
|
registerTool(
|
|
3016
3714
|
server,
|
|
3017
3715
|
"list_tags",
|
|
3018
|
-
"List all tags available for a given entity type (parties, opportunities, or kases).",
|
|
3716
|
+
"List all tags available for a given entity type (parties, opportunities, or kases). Returns each tag's id, name, and any data-tag field schema. Tags are entity-specific \u2014 a party tag is not interchangeable with an opportunity tag. Use this to discover valid tag ids before calling add_tag, or to display the tag catalogue to the user when they ask 'what tags do we use?'",
|
|
3019
3717
|
listTagsSchema,
|
|
3020
3718
|
listTags
|
|
3021
3719
|
);
|
|
@@ -3034,11 +3732,25 @@ function createCapsuleMcpServer() {
|
|
|
3034
3732
|
removeTagByIdSchema,
|
|
3035
3733
|
removeTagById
|
|
3036
3734
|
);
|
|
3735
|
+
registerBatchTool(
|
|
3736
|
+
server,
|
|
3737
|
+
"batch_add_tag",
|
|
3738
|
+
"Attach tags to many entities in parallel \u2014 e.g. tag a list of 20 contacts as 'RSAC26' after a conference, or apply the 'Departed' tag to 10 people in a layoff batch. Pass `items: [{ entity, entityId, tagName }, ...]` (1\u201350 items). Each item is processed identically to a single add_tag call. Connector fans out parallel HTTP requests, default cap 5 (CAPSULE_MCP_BATCH_CONCURRENCY). Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }. The list_tags cache is invalidated for each affected entity type.",
|
|
3739
|
+
batchAddTagSchema,
|
|
3740
|
+
batchAddTag
|
|
3741
|
+
);
|
|
3742
|
+
registerBatchTool(
|
|
3743
|
+
server,
|
|
3744
|
+
"batch_remove_tag_by_id",
|
|
3745
|
+
"Detach tags from many entities in parallel \u2014 cleanup counterpart to batch_add_tag. Pass `items: [{ entity, entityId, tagId }, ...]` (1\u201350 items). Each item is processed identically to a single remove_tag_by_id call (already-detached tags are reported as idempotent successes, not failures). Connector fans out parallel HTTP requests, default cap 5. Returns { results: [{ok, ...} per item], summary: {total, succeeded, failed} }.",
|
|
3746
|
+
batchRemoveTagByIdSchema,
|
|
3747
|
+
batchRemoveTagById
|
|
3748
|
+
);
|
|
3037
3749
|
}
|
|
3038
3750
|
registerTool(
|
|
3039
3751
|
server,
|
|
3040
3752
|
"list_users",
|
|
3041
|
-
"List all users in the Capsule account.",
|
|
3753
|
+
"List all users in the Capsule account. Returns each user's id, username, optional first/last name, role, and party reference. Some users may have null first/last name fields (only username set) \u2014 fall back to username for display. Use this to discover user ids for owner-filtered queries against opportunities, projects, and tasks, or to map a user to their party record via user.party.id.",
|
|
3042
3754
|
listUsersSchema,
|
|
3043
3755
|
listUsers
|
|
3044
3756
|
);
|
|
@@ -3059,6 +3771,20 @@ function secretDigest(value) {
|
|
|
3059
3771
|
function timingSafeSecretEqual(provided, expected) {
|
|
3060
3772
|
return timingSafeEqual3(secretDigest(provided), secretDigest(expected));
|
|
3061
3773
|
}
|
|
3774
|
+
var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
3775
|
+
var DEFAULT_MCP_RATE_LIMIT_MAX = 600;
|
|
3776
|
+
var MAX_MEMORY_STORE_WINDOW_MS = 2 ** 31 - 1;
|
|
3777
|
+
function resolveMcpRateLimitConfig() {
|
|
3778
|
+
const windowMs = Math.min(
|
|
3779
|
+
readPositiveInt("MCP_HTTP_RATE_LIMIT_WINDOW_MS", DEFAULT_MCP_RATE_LIMIT_WINDOW_MS),
|
|
3780
|
+
MAX_MEMORY_STORE_WINDOW_MS
|
|
3781
|
+
);
|
|
3782
|
+
return {
|
|
3783
|
+
windowMs,
|
|
3784
|
+
limit: readPositiveInt("MCP_HTTP_RATE_LIMIT_MAX", DEFAULT_MCP_RATE_LIMIT_MAX),
|
|
3785
|
+
disabled: process.env["MCP_HTTP_RATE_LIMIT_DISABLED"] === "1"
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3062
3788
|
function createApp(opts) {
|
|
3063
3789
|
const { oauthProvider: oauthProvider2, issuerUrl: issuerUrl2, jsonLimit: jsonLimit2, allowedOrigins } = opts;
|
|
3064
3790
|
const resourceName = opts.resourceName ?? "Capsule CRM MCP";
|
|
@@ -3150,9 +3876,11 @@ function createApp(opts) {
|
|
|
3150
3876
|
}
|
|
3151
3877
|
next();
|
|
3152
3878
|
};
|
|
3153
|
-
const
|
|
3154
|
-
|
|
3155
|
-
|
|
3879
|
+
const {
|
|
3880
|
+
windowMs: rateLimitWindowMs,
|
|
3881
|
+
limit: rateLimitMax,
|
|
3882
|
+
disabled: rateLimitDisabled
|
|
3883
|
+
} = resolveMcpRateLimitConfig();
|
|
3156
3884
|
const mcpRateLimit = rateLimit({
|
|
3157
3885
|
windowMs: rateLimitWindowMs,
|
|
3158
3886
|
limit: rateLimitMax,
|
|
@@ -3160,7 +3888,8 @@ function createApp(opts) {
|
|
|
3160
3888
|
legacyHeaders: false,
|
|
3161
3889
|
keyGenerator: (req) => {
|
|
3162
3890
|
const clientId = req.auth?.clientId;
|
|
3163
|
-
|
|
3891
|
+
if (clientId) return clientId;
|
|
3892
|
+
return ipKeyGenerator(req.ip ?? "");
|
|
3164
3893
|
},
|
|
3165
3894
|
skip: () => rateLimitDisabled,
|
|
3166
3895
|
handler: (_req, res) => {
|
|
@@ -3198,14 +3927,17 @@ function createApp(opts) {
|
|
|
3198
3927
|
express.json({ limit: jsonLimit2 }),
|
|
3199
3928
|
async (req, res) => {
|
|
3200
3929
|
try {
|
|
3201
|
-
const
|
|
3930
|
+
const clientId = req.auth?.clientId;
|
|
3931
|
+
const server = createCapsuleMcpServer({ clientId });
|
|
3202
3932
|
const transport = new StreamableHTTPServerTransport({});
|
|
3203
3933
|
res.on("close", () => {
|
|
3204
3934
|
void transport.close();
|
|
3205
3935
|
void server.close();
|
|
3206
3936
|
});
|
|
3207
|
-
await
|
|
3208
|
-
|
|
3937
|
+
await withRequestContext({ clientId }, async () => {
|
|
3938
|
+
await server.connect(transport);
|
|
3939
|
+
await transport.handleRequest(req, res, req.body);
|
|
3940
|
+
});
|
|
3209
3941
|
} catch (err) {
|
|
3210
3942
|
const name = err instanceof Error ? err.name : typeof err;
|
|
3211
3943
|
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : void 0;
|