adoptai-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +70 -0
  2. package/bin/adoptai-mcp.js +2 -0
  3. package/dist/apps/canva.js +1 -0
  4. package/dist/apps/figma.js +1 -0
  5. package/dist/apps/github.js +2 -0
  6. package/dist/apps/notion.js +1 -0
  7. package/dist/apps/registry.js +20 -0
  8. package/dist/apps/salesforce.js +1 -0
  9. package/dist/cli/add.js +532 -0
  10. package/dist/cli/index.js +39 -0
  11. package/dist/cli/list.js +19 -0
  12. package/dist/cli/remove.js +37 -0
  13. package/dist/cli/serve.js +27 -0
  14. package/dist/cli/status.js +24 -0
  15. package/dist/config/clients.js +118 -0
  16. package/dist/config/credentials.js +34 -0
  17. package/dist/core/auth-manager.js +237 -0
  18. package/dist/core/config-writer.js +161 -0
  19. package/dist/core/doctor.js +199 -0
  20. package/dist/core/package.json +3 -0
  21. package/dist/core/server-base.js +81 -0
  22. package/dist/integrations/canva/.env +3 -0
  23. package/dist/integrations/canva/auth.js +287 -0
  24. package/dist/integrations/canva/env.js +9 -0
  25. package/dist/integrations/canva/index.js +12 -0
  26. package/dist/integrations/canva/package.json +31 -0
  27. package/dist/integrations/canva/publish-to-adoptai.js +365 -0
  28. package/dist/integrations/canva/setup.js +90 -0
  29. package/dist/integrations/canva/tools.js +1315 -0
  30. package/dist/integrations/canva/tools.original.js +1315 -0
  31. package/dist/integrations/figma/auth.js +48 -0
  32. package/dist/integrations/figma/index.js +11 -0
  33. package/dist/integrations/figma/package.json +27 -0
  34. package/dist/integrations/figma/publish-to-adoptai.js +384 -0
  35. package/dist/integrations/figma/setup.js +90 -0
  36. package/dist/integrations/figma/tools.js +1137 -0
  37. package/dist/integrations/github/auth.js +53 -0
  38. package/dist/integrations/github/index.js +11 -0
  39. package/dist/integrations/github/package.json +28 -0
  40. package/dist/integrations/github/publish-to-adoptai.js +240 -0
  41. package/dist/integrations/github/setup.js +103 -0
  42. package/dist/integrations/github/tools.js +78 -0
  43. package/dist/integrations/github-actions/auth.js +53 -0
  44. package/dist/integrations/github-actions/index.js +11 -0
  45. package/dist/integrations/github-actions/package.json +27 -0
  46. package/dist/integrations/github-actions/setup.js +103 -0
  47. package/dist/integrations/github-actions/tools.js +5642 -0
  48. package/dist/integrations/github-activity/auth.js +53 -0
  49. package/dist/integrations/github-activity/index.js +11 -0
  50. package/dist/integrations/github-activity/package.json +27 -0
  51. package/dist/integrations/github-activity/setup.js +103 -0
  52. package/dist/integrations/github-activity/tools.js +925 -0
  53. package/dist/integrations/github-apps/auth.js +53 -0
  54. package/dist/integrations/github-apps/index.js +11 -0
  55. package/dist/integrations/github-apps/package.json +27 -0
  56. package/dist/integrations/github-apps/setup.js +103 -0
  57. package/dist/integrations/github-apps/tools.js +791 -0
  58. package/dist/integrations/github-billing/auth.js +53 -0
  59. package/dist/integrations/github-billing/index.js +11 -0
  60. package/dist/integrations/github-billing/package.json +27 -0
  61. package/dist/integrations/github-billing/setup.js +103 -0
  62. package/dist/integrations/github-billing/tools.js +438 -0
  63. package/dist/integrations/github-checks/auth.js +53 -0
  64. package/dist/integrations/github-checks/index.js +11 -0
  65. package/dist/integrations/github-checks/package.json +27 -0
  66. package/dist/integrations/github-checks/setup.js +103 -0
  67. package/dist/integrations/github-checks/tools.js +607 -0
  68. package/dist/integrations/github-code-scanning/auth.js +53 -0
  69. package/dist/integrations/github-code-scanning/index.js +11 -0
  70. package/dist/integrations/github-code-scanning/package.json +27 -0
  71. package/dist/integrations/github-code-scanning/setup.js +103 -0
  72. package/dist/integrations/github-code-scanning/tools.js +987 -0
  73. package/dist/integrations/github-dependabot/auth.js +53 -0
  74. package/dist/integrations/github-dependabot/index.js +11 -0
  75. package/dist/integrations/github-dependabot/package.json +27 -0
  76. package/dist/integrations/github-dependabot/setup.js +103 -0
  77. package/dist/integrations/github-dependabot/tools.js +915 -0
  78. package/dist/integrations/github-gists/auth.js +53 -0
  79. package/dist/integrations/github-gists/index.js +11 -0
  80. package/dist/integrations/github-gists/package.json +27 -0
  81. package/dist/integrations/github-gists/setup.js +103 -0
  82. package/dist/integrations/github-gists/tools.js +545 -0
  83. package/dist/integrations/github-git/auth.js +53 -0
  84. package/dist/integrations/github-git/index.js +11 -0
  85. package/dist/integrations/github-git/package.json +27 -0
  86. package/dist/integrations/github-git/setup.js +103 -0
  87. package/dist/integrations/github-git/tools.js +513 -0
  88. package/dist/integrations/github-issues/auth.js +53 -0
  89. package/dist/integrations/github-issues/index.js +11 -0
  90. package/dist/integrations/github-issues/package.json +27 -0
  91. package/dist/integrations/github-issues/setup.js +103 -0
  92. package/dist/integrations/github-issues/tools.js +2232 -0
  93. package/dist/integrations/github-orgs/auth.js +53 -0
  94. package/dist/integrations/github-orgs/index.js +11 -0
  95. package/dist/integrations/github-orgs/package.json +27 -0
  96. package/dist/integrations/github-orgs/setup.js +103 -0
  97. package/dist/integrations/github-orgs/tools.js +3512 -0
  98. package/dist/integrations/github-packages/auth.js +53 -0
  99. package/dist/integrations/github-packages/index.js +11 -0
  100. package/dist/integrations/github-packages/package.json +27 -0
  101. package/dist/integrations/github-packages/setup.js +103 -0
  102. package/dist/integrations/github-packages/tools.js +1088 -0
  103. package/dist/integrations/github-pulls/auth.js +53 -0
  104. package/dist/integrations/github-pulls/index.js +11 -0
  105. package/dist/integrations/github-pulls/package.json +27 -0
  106. package/dist/integrations/github-pulls/setup.js +103 -0
  107. package/dist/integrations/github-pulls/tools.js +1252 -0
  108. package/dist/integrations/github-reactions/auth.js +53 -0
  109. package/dist/integrations/github-reactions/index.js +11 -0
  110. package/dist/integrations/github-reactions/package.json +27 -0
  111. package/dist/integrations/github-reactions/setup.js +103 -0
  112. package/dist/integrations/github-reactions/tools.js +706 -0
  113. package/dist/integrations/github-repos/auth.js +53 -0
  114. package/dist/integrations/github-repos/index.js +11 -0
  115. package/dist/integrations/github-repos/package.json +27 -0
  116. package/dist/integrations/github-repos/setup.js +103 -0
  117. package/dist/integrations/github-repos/tools.js +7286 -0
  118. package/dist/integrations/github-search/auth.js +53 -0
  119. package/dist/integrations/github-search/index.js +11 -0
  120. package/dist/integrations/github-search/package.json +27 -0
  121. package/dist/integrations/github-search/setup.js +103 -0
  122. package/dist/integrations/github-search/tools.js +370 -0
  123. package/dist/integrations/github-teams/auth.js +53 -0
  124. package/dist/integrations/github-teams/index.js +11 -0
  125. package/dist/integrations/github-teams/package.json +27 -0
  126. package/dist/integrations/github-teams/setup.js +103 -0
  127. package/dist/integrations/github-teams/tools.js +633 -0
  128. package/dist/integrations/github-users/auth.js +53 -0
  129. package/dist/integrations/github-users/index.js +11 -0
  130. package/dist/integrations/github-users/package.json +27 -0
  131. package/dist/integrations/github-users/setup.js +103 -0
  132. package/dist/integrations/github-users/tools.js +1118 -0
  133. package/dist/integrations/notion/api.js +108 -0
  134. package/dist/integrations/notion/auth.js +59 -0
  135. package/dist/integrations/notion/endpoints.json +630 -0
  136. package/dist/integrations/notion/index.js +11 -0
  137. package/dist/integrations/notion/package.json +33 -0
  138. package/dist/integrations/notion/publish-to-adoptai.js +271 -0
  139. package/dist/integrations/notion/scripts/generate-endpoints.mjs +306 -0
  140. package/dist/integrations/notion/setup.js +89 -0
  141. package/dist/integrations/notion/tools.js +586 -0
  142. package/dist/integrations/notion/tools.original.js +568 -0
  143. package/dist/integrations/salesforce/.env +8 -0
  144. package/dist/integrations/salesforce/.env.example +15 -0
  145. package/dist/integrations/salesforce/auth.js +311 -0
  146. package/dist/integrations/salesforce/endpoints.json +1359 -0
  147. package/dist/integrations/salesforce/env.js +9 -0
  148. package/dist/integrations/salesforce/index.js +12 -0
  149. package/dist/integrations/salesforce/package.json +42 -0
  150. package/dist/integrations/salesforce/publish-smart-specs.js +890 -0
  151. package/dist/integrations/salesforce/publish-to-adoptai.js +386 -0
  152. package/dist/integrations/salesforce/scripts/extract-postman.mjs +222 -0
  153. package/dist/integrations/salesforce/setup.js +112 -0
  154. package/dist/integrations/salesforce/tools.js +4544 -0
  155. package/dist/integrations/salesforce/tools.original.js +4487 -0
  156. package/dist/server/mcp-server.js +50 -0
  157. package/dist/server/tool-loader.js +47 -0
  158. package/dist/specs/figma-api.json +13621 -0
  159. package/dist/specs/split/salesforce-auth.json +3931 -0
  160. package/dist/specs/split/salesforce-bulk-v1.json +1489 -0
  161. package/dist/specs/split/salesforce-bulk-v2.json +1951 -0
  162. package/dist/specs/split/salesforce-composite.json +1246 -0
  163. package/dist/specs/split/salesforce-connect.json +11639 -0
  164. package/dist/specs/split/salesforce-einstein-prediction-service.json +576 -0
  165. package/dist/specs/split/salesforce-event-platform.json +2682 -0
  166. package/dist/specs/split/salesforce-graphql.json +1754 -0
  167. package/dist/specs/split/salesforce-industries.json +4115 -0
  168. package/dist/specs/split/salesforce-metadata.json +555 -0
  169. package/dist/specs/split/salesforce-rest.json +4798 -0
  170. package/dist/specs/split/salesforce-soap.json +210 -0
  171. package/dist/specs/split/salesforce-subscription-management.json +1299 -0
  172. package/dist/specs/split/salesforce-tooling.json +2026 -0
  173. package/dist/specs/split/salesforce-ui.json +7426 -0
  174. package/package.json +47 -0
@@ -0,0 +1,4544 @@
1
+ import './env.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import axios from 'axios';
7
+ import {
8
+ buildAuthHeaders,
9
+ ensureAccessToken,
10
+ getDefaultUserId,
11
+ getInstanceUrl,
12
+ getToken,
13
+ } from './auth.js';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const manifest = JSON.parse(readFileSync(join(__dirname, 'endpoints.json'), 'utf8'));
17
+
18
+ /** Postman split: Bulk API v2 (Section B — same repo path; optional if file missing). */
19
+ const BULK_V2_POSTMAN = join(__dirname, '../../specs/split/salesforce-bulk-v2.json');
20
+
21
+ /** Postman split: Bulk API v1 + Bulk query jobs (Section B). */
22
+ const BULK_V1_POSTMAN = join(__dirname, '../../specs/split/salesforce-bulk-v1.json');
23
+
24
+ /** Postman split: Metadata SOAP API (Section B). */
25
+ const METADATA_POSTMAN = join(__dirname, '../../specs/split/salesforce-metadata.json');
26
+
27
+ /** Postman split: Connect / Chatter API (Section B). */
28
+ const CONNECT_POSTMAN = join(__dirname, '../../specs/split/salesforce-connect.json');
29
+
30
+ /** Postman split: Tooling API (Section B). */
31
+ const TOOLING_POSTMAN = join(__dirname, '../../specs/split/salesforce-tooling.json');
32
+
33
+ /** Postman split: UI API (Section B). */
34
+ const UI_POSTMAN = join(__dirname, '../../specs/split/salesforce-ui.json');
35
+
36
+ /** Postman split: Einstein Prediction Service / Smart Data Discovery (Section B). */
37
+ const EINSTEIN_PS_POSTMAN = join(__dirname, '../../specs/split/salesforce-einstein-prediction-service.json');
38
+
39
+ /** Postman split: Event Platform (CDC, platform events, streaming; Section B). */
40
+ const EVENT_PLATFORM_POSTMAN = join(__dirname, '../../specs/split/salesforce-event-platform.json');
41
+
42
+ /** Postman split: GraphQL API (Section B). */
43
+ const GRAPHQL_POSTMAN = join(__dirname, '../../specs/split/salesforce-graphql.json');
44
+
45
+ /** Postman split: Subscription Management / commerce (Section B). */
46
+ const SUBSCRIPTION_MGMT_POSTMAN = join(__dirname, '../../specs/split/salesforce-subscription-management.json');
47
+
48
+ /** Postman split: Industries (CPQ, Loyalty, Health, FSC patterns; Section B). */
49
+ const INDUSTRIES_POSTMAN = join(__dirname, '../../specs/split/salesforce-industries.json');
50
+
51
+ /** Dynamic API base — never hardcode instance host (see setup env + OAuth). */
52
+ function getBaseUrl() {
53
+ const inst =
54
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
55
+ if (!inst) {
56
+ throw new Error(
57
+ 'SALESFORCE_INSTANCE_URL is not set. Run setup and ensure your MCP config includes SALESFORCE_INSTANCE_URL, or authenticate again.'
58
+ );
59
+ }
60
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
61
+ const ver = verRaw.startsWith('v') ? verRaw : `v${verRaw}`;
62
+ return `${inst}/services/data/${ver}`;
63
+ }
64
+
65
+ /**
66
+ * Bulk API 1.0 async root: `{instance}/services/async/{numericVersion}` (no `v` prefix).
67
+ * Matches Salesforce `/services/async/59.0/...` style paths.
68
+ */
69
+ function getBulkV1AsyncBaseUrl() {
70
+ const inst =
71
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
72
+ if (!inst) {
73
+ throw new Error(
74
+ 'SALESFORCE_INSTANCE_URL is not set. Run setup and ensure your MCP config includes SALESFORCE_INSTANCE_URL, or authenticate again.'
75
+ );
76
+ }
77
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
78
+ const numVer = String(verRaw).replace(/^v/i, '');
79
+ return `${inst}/services/async/${numVer}`;
80
+ }
81
+
82
+ /** Tooling API root: `{instance}/services/data/{version}/tooling`. */
83
+ function getToolingBaseUrl() {
84
+ const inst =
85
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
86
+ if (!inst) {
87
+ throw new Error(
88
+ 'SALESFORCE_INSTANCE_URL is not set. Run setup and ensure your MCP config includes SALESFORCE_INSTANCE_URL, or authenticate again.'
89
+ );
90
+ }
91
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
92
+ const ver = verRaw.startsWith('v') ? verRaw : `v${verRaw}`;
93
+ return `${inst}/services/data/${ver}/tooling`;
94
+ }
95
+
96
+ /** UI API root: `{instance}/services/data/{version}/ui-api`. */
97
+ function getUiApiBaseUrl() {
98
+ const inst =
99
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
100
+ if (!inst) {
101
+ throw new Error(
102
+ 'SALESFORCE_INSTANCE_URL is not set. Run setup and ensure your MCP config includes SALESFORCE_INSTANCE_URL, or authenticate again.'
103
+ );
104
+ }
105
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
106
+ const ver = verRaw.startsWith('v') ? verRaw : `v${verRaw}`;
107
+ return `${inst}/services/data/${ver}/ui-api`;
108
+ }
109
+
110
+ /** GraphQL API endpoint: `{instance}/services/data/{version}/graphql`. */
111
+ function getGraphqlBaseUrl() {
112
+ const inst =
113
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
114
+ if (!inst) {
115
+ throw new Error(
116
+ 'SALESFORCE_INSTANCE_URL is not set. Run setup and ensure your MCP config includes SALESFORCE_INSTANCE_URL, or authenticate again.'
117
+ );
118
+ }
119
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
120
+ const ver = verRaw.startsWith('v') ? verRaw : `v${verRaw}`;
121
+ return `${inst}/services/data/${ver}/graphql`;
122
+ }
123
+
124
+ /**
125
+ * Einstein Vision/Language Platform API root (default `https://api.einstein.ai/v2`).
126
+ * Set `SALESFORCE_EINSTEIN_PLATFORM_URL` to override.
127
+ */
128
+ function getEinsteinPlatformBaseUrl() {
129
+ const raw = process.env.SALESFORCE_EINSTEIN_PLATFORM_URL?.replace(/\/$/, '') || 'https://api.einstein.ai/v2';
130
+ return raw;
131
+ }
132
+
133
+ async function einsteinPlatformPostMultipart(pathSuffix, buildForm) {
134
+ const envTok = process.env.SALESFORCE_EINSTEIN_ACCESS_TOKEN?.trim() || process.env.EINSTEIN_ACCESS_TOKEN?.trim();
135
+ if (!envTok) await ensureAccessToken();
136
+ const token = envTok || getToken();
137
+ const base = getEinsteinPlatformBaseUrl().replace(/\/$/, '');
138
+ const path = pathSuffix.startsWith('/') ? pathSuffix : `/${pathSuffix}`;
139
+ const url = `${base}${path}`;
140
+ const form = new FormData();
141
+ buildForm(form);
142
+ let res;
143
+ try {
144
+ res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
145
+ } catch (e) {
146
+ throw new Error(e?.message || 'Einstein Platform request failed');
147
+ }
148
+ const txt = await res.text();
149
+ let data;
150
+ try {
151
+ data = txt ? JSON.parse(txt) : {};
152
+ } catch {
153
+ throw new Error(`Einstein Platform response not JSON (${res.status}): ${txt.slice(0, 500)}`);
154
+ }
155
+ if (!res.ok) {
156
+ handleError({ response: { status: res.status, data, statusText: res.statusText } });
157
+ }
158
+ return data;
159
+ }
160
+
161
+ function getDataCatalogUrl() {
162
+ const inst =
163
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
164
+ if (!inst) {
165
+ throw new Error('SALESFORCE_INSTANCE_URL is not set.');
166
+ }
167
+ return `${inst}/services/data`;
168
+ }
169
+
170
+ function getApexBaseUrl() {
171
+ const inst =
172
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
173
+ if (!inst) throw new Error('SALESFORCE_INSTANCE_URL is not set.');
174
+ return `${inst}/services/apexrest`;
175
+ }
176
+
177
+ function handleError(err) {
178
+ const status = err.response?.status;
179
+ const data = err.response?.data;
180
+ const msg =
181
+ data?.[0]?.message ||
182
+ data?.message ||
183
+ data?.error_description ||
184
+ data?.error ||
185
+ err.message;
186
+ if (status === 401) {
187
+ throw new Error(
188
+ 'Token invalid or expired.\nRun: npx adoptai-salesforce-mcp --client cursor'
189
+ );
190
+ }
191
+ if (status === 403) throw new Error('Insufficient permissions. Check your connected app scopes.');
192
+ if (status === 404) throw new Error('Resource not found. Check your parameters.');
193
+ if (status === 429) throw new Error('Rate limit exceeded. Please wait and try again.');
194
+ throw new Error(msg || 'API request failed');
195
+ }
196
+
197
+ function applyPathTemplate(path, pathValues) {
198
+ let p = path;
199
+ for (const [k, v] of Object.entries(pathValues)) {
200
+ p = p.replace(`{${k}}`, encodeURIComponent(String(v)));
201
+ }
202
+ if (/\{[^}]+}/.test(p)) {
203
+ throw new Error(`Missing path parameters for: ${p}`);
204
+ }
205
+ return p;
206
+ }
207
+
208
+ function text(payload) {
209
+ return {
210
+ content: [{ type: 'text', text: JSON.stringify(payload ?? {}, null, 2) }],
211
+ };
212
+ }
213
+
214
+ function splitPathAndRest(path, params) {
215
+ const pathKeys = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
216
+ const pathValues = {};
217
+ const rest = { ...params };
218
+ for (const k of pathKeys) {
219
+ if (rest[k] === undefined || rest[k] === null || rest[k] === '') {
220
+ throw new Error(`Missing required path parameter: ${k}`);
221
+ }
222
+ pathValues[k] = rest[k];
223
+ delete rest[k];
224
+ }
225
+ delete rest._rawBody;
226
+ return { pathValues, rest };
227
+ }
228
+
229
+ async function sfRequest(method, path, urlKind, params = {}) {
230
+ await ensureAccessToken();
231
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
232
+ const rawBody = params._rawBody;
233
+ const { pathValues, rest } = splitPathAndRest(path, params);
234
+ const resolvedPath = applyPathTemplate(path, pathValues);
235
+
236
+ let url;
237
+ if (urlKind === 'apex') {
238
+ url = getApexBaseUrl() + resolvedPath;
239
+ } else if (urlKind === 'data-catalog') {
240
+ url = getDataCatalogUrl() + (resolvedPath === '/' ? '' : resolvedPath);
241
+ } else if (urlKind === 'tooling') {
242
+ url = getToolingBaseUrl() + resolvedPath;
243
+ } else if (urlKind === 'ui-api') {
244
+ url = getUiApiBaseUrl() + resolvedPath;
245
+ } else if (urlKind === 'graphql') {
246
+ url = getGraphqlBaseUrl();
247
+ } else if (urlKind === 'bulk-v1-async') {
248
+ url = getBulkV1AsyncBaseUrl() + resolvedPath;
249
+ } else {
250
+ url = getBaseUrl() + resolvedPath;
251
+ }
252
+
253
+ const isGetLike = method === 'GET' || method === 'DELETE';
254
+ try {
255
+ const res = await axios({
256
+ method,
257
+ url,
258
+ headers,
259
+ params: isGetLike ? rest : undefined,
260
+ data: !isGetLike ? (rawBody !== undefined ? rawBody : Object.keys(rest).length ? rest : undefined) : undefined,
261
+ timeout: 120000,
262
+ validateStatus: () => true,
263
+ });
264
+ if (res.status < 200 || res.status >= 300) {
265
+ handleError({ response: res, message: res.statusText });
266
+ }
267
+ if (res.status === 204 || res.data === '' || res.data == null) {
268
+ return { success: true, status: res.status };
269
+ }
270
+ return res.data;
271
+ } catch (err) {
272
+ if (err.response) handleError(err);
273
+ throw err;
274
+ }
275
+ }
276
+
277
+ function escapeSoqlString(s) {
278
+ return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
279
+ }
280
+
281
+ function soqlQueryUrl(q) {
282
+ return `${getBaseUrl()}/query/?q=${encodeURIComponent(q)}`;
283
+ }
284
+
285
+ async function soqlGet(queryString) {
286
+ await ensureAccessToken();
287
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
288
+ try {
289
+ const res = await axios.get(soqlQueryUrl(queryString), { headers, timeout: 120000 });
290
+ return res.data;
291
+ } catch (err) {
292
+ handleError(err);
293
+ }
294
+ }
295
+
296
+ async function fetchQueryAllPages(initialQuery, maxRecords) {
297
+ await ensureAccessToken();
298
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
299
+ let url = soqlQueryUrl(initialQuery);
300
+ const allRecords = [];
301
+ let done = false;
302
+ let totalFetched = 0;
303
+
304
+ while (!done && totalFetched < maxRecords) {
305
+ let res;
306
+ try {
307
+ res = await axios.get(url, { headers, timeout: 120000 });
308
+ } catch (e) {
309
+ handleError(e);
310
+ }
311
+ const data = res.data;
312
+ const batch = data.records || [];
313
+ for (const row of batch) {
314
+ if (totalFetched >= maxRecords) break;
315
+ allRecords.push(row);
316
+ totalFetched += 1;
317
+ }
318
+ done = data.done === true || !data.nextRecordsUrl;
319
+ if (!done && totalFetched < maxRecords) {
320
+ const next = data.nextRecordsUrl;
321
+ const inst = getInstanceUrl()?.replace(/\/$/, '');
322
+ if (!inst) throw new Error('SALESFORCE_INSTANCE_URL missing for pagination');
323
+ url = next.startsWith('http') ? next : `${inst}${next}`;
324
+ } else {
325
+ done = true;
326
+ }
327
+ }
328
+
329
+ return {
330
+ totalSize: allRecords.length,
331
+ maxRecords,
332
+ truncated: totalFetched >= maxRecords && !done,
333
+ records: allRecords,
334
+ };
335
+ }
336
+
337
+ function buildSearchWhere(conditions) {
338
+ const parts = [];
339
+ for (const [field, val] of Object.entries(conditions)) {
340
+ if (val == null || val === '') continue;
341
+ parts.push(`${field} LIKE '%${escapeSoqlString(val)}%'`);
342
+ }
343
+ return parts.length ? parts.join(' AND ') : null;
344
+ }
345
+
346
+ function pipelinePeriodClause(period) {
347
+ switch (period) {
348
+ case 'this_quarter':
349
+ return 'CloseDate = THIS_QUARTER';
350
+ case 'next_quarter':
351
+ return 'CloseDate = NEXT_QUARTER';
352
+ case 'this_month':
353
+ default:
354
+ return 'CloseDate = THIS_MONTH';
355
+ }
356
+ }
357
+
358
+ // ——— Bulk API v2 helpers & Section B extract (salesforce-bulk-v2.json) ———
359
+
360
+ function chunkArray(arr, size) {
361
+ const n = Math.max(1, Number(size) || 200);
362
+ const chunks = [];
363
+ for (let i = 0; i < arr.length; i += n) {
364
+ chunks.push(arr.slice(i, i + n));
365
+ }
366
+ return chunks;
367
+ }
368
+
369
+ function csvEscapeCell(val) {
370
+ if (val == null) return '';
371
+ const s = String(val);
372
+ if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
373
+ return s;
374
+ }
375
+
376
+ function objectsToCsv(rows) {
377
+ if (!rows?.length) return '';
378
+ const headers = [...new Set(rows.flatMap((r) => Object.keys(r)))];
379
+ const lines = [headers.map(csvEscapeCell).join(',')];
380
+ for (const row of rows) {
381
+ lines.push(headers.map((h) => csvEscapeCell(row[h])).join(','));
382
+ }
383
+ return lines.join('\n');
384
+ }
385
+
386
+ function recordIdsToDeleteCsv(record_ids) {
387
+ if (!record_ids?.length) return '';
388
+ const ids = record_ids.map((id) => String(id).trim()).filter(Boolean);
389
+ return ['Id', ...ids].join('\n');
390
+ }
391
+
392
+ async function pollBulkIngestJob(jobId, options = {}) {
393
+ const maxWaitMs = options.maxWaitMs ?? 30 * 60 * 1000;
394
+ const intervalMs = options.intervalMs ?? 2000;
395
+ const base = getBaseUrl();
396
+ await ensureAccessToken();
397
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
398
+ const deadline = Date.now() + maxWaitMs;
399
+ while (Date.now() < deadline) {
400
+ const res = await axios.get(`${base}/jobs/ingest/${encodeURIComponent(jobId)}`, {
401
+ headers,
402
+ timeout: 120000,
403
+ validateStatus: () => true,
404
+ });
405
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
406
+ const state = res.data.state;
407
+ if (state === 'JobComplete' || state === 'Failed' || state === 'Aborted') {
408
+ return res.data;
409
+ }
410
+ await new Promise((r) => setTimeout(r, intervalMs));
411
+ }
412
+ throw new Error(`Bulk ingest job ${jobId} did not finish within ${maxWaitMs}ms`);
413
+ }
414
+
415
+ async function runBulkIngestOperation({
416
+ objectType,
417
+ operation,
418
+ externalIdFieldName,
419
+ records,
420
+ recordIds,
421
+ batch_size,
422
+ }) {
423
+ await ensureAccessToken();
424
+ const base = getBaseUrl();
425
+ const auth = buildAuthHeaders();
426
+ const jobBody = {
427
+ object: objectType,
428
+ operation,
429
+ contentType: 'CSV',
430
+ lineEnding: 'LF',
431
+ columnDelimiter: 'COMMA',
432
+ };
433
+ if (externalIdFieldName) {
434
+ jobBody.externalIdFieldName = externalIdFieldName;
435
+ }
436
+
437
+ const jobRes = await axios.post(`${base}/jobs/ingest`, jobBody, {
438
+ headers: { ...auth, 'Content-Type': 'application/json' },
439
+ timeout: 120000,
440
+ validateStatus: () => true,
441
+ });
442
+ if (jobRes.status < 200 || jobRes.status >= 300) handleError({ response: jobRes });
443
+
444
+ const jobId = jobRes.data.id;
445
+ let payloads = [];
446
+
447
+ if (operation === 'delete') {
448
+ const csv = recordIdsToDeleteCsv(recordIds);
449
+ if (!csv) throw new Error('record_ids must be a non-empty array for delete');
450
+ payloads = [csv];
451
+ } else {
452
+ if (!records?.length) throw new Error('records must be a non-empty array');
453
+ payloads = chunkArray(records, batch_size).map(objectsToCsv);
454
+ }
455
+
456
+ for (const csv of payloads) {
457
+ const putRes = await axios.put(`${base}/jobs/ingest/${jobId}/batches`, csv, {
458
+ headers: { ...auth, 'Content-Type': 'text/csv', Accept: 'application/json' },
459
+ timeout: 120000,
460
+ validateStatus: () => true,
461
+ });
462
+ if (putRes.status < 200 || putRes.status >= 300) handleError({ response: putRes });
463
+ }
464
+
465
+ const patchRes = await axios.patch(
466
+ `${base}/jobs/ingest/${jobId}`,
467
+ { state: 'UploadComplete' },
468
+ {
469
+ headers: { ...auth, 'Content-Type': 'application/json' },
470
+ timeout: 120000,
471
+ validateStatus: () => true,
472
+ }
473
+ );
474
+ if (patchRes.status < 200 || patchRes.status >= 300) handleError({ response: patchRes });
475
+
476
+ const job = await pollBulkIngestJob(jobId);
477
+ return { jobId, job };
478
+ }
479
+
480
+ function bulkApiMultipliedTools() {
481
+ return [
482
+ {
483
+ name: 'bulk_create_records',
484
+ description:
485
+ 'Creates multiple Salesforce records of the same object type in a single batch operation using Bulk API v2, automatically handling job creation, data upload, and completion polling to return success/failure results for each record. Use this instead of individual record creation when processing 10+ records for dramatically better performance, but note that all records must be of the same object type',
486
+ inputSchema: {
487
+ type: 'object',
488
+ properties: {
489
+ object_type: { type: 'string', description: 'SObject API name (e.g. Account, Contact)' },
490
+ records: { type: 'array', description: 'Row objects; columns become CSV headers' },
491
+ batch_size: { type: 'number', description: 'Rows per upload batch (default 200)' },
492
+ },
493
+ required: ['object_type', 'records'],
494
+ },
495
+ handler: async ({ object_type, records, batch_size }) => {
496
+ const result = await runBulkIngestOperation({
497
+ objectType: object_type,
498
+ operation: 'insert',
499
+ records,
500
+ batch_size: batch_size ?? 200,
501
+ });
502
+ return text(result);
503
+ },
504
+ },
505
+ {
506
+ name: 'bulk_update_records',
507
+ description:
508
+ 'Updates multiple existing Salesforce records in a single batch operation, requiring each record to include its Id field for identification. Use this for mass updates like changing record ownership, status fields, or other bulk modifications across many records when you know the record Ids.',
509
+ inputSchema: {
510
+ type: 'object',
511
+ properties: {
512
+ object_type: { type: 'string' },
513
+ records: {
514
+ type: 'array',
515
+ description: 'Objects including Id plus fields to update',
516
+ },
517
+ batch_size: { type: 'number', description: 'Default 200' },
518
+ },
519
+ required: ['object_type', 'records'],
520
+ },
521
+ handler: async ({ object_type, records, batch_size }) => {
522
+ for (const r of records) {
523
+ if (r.Id == null && r.id == null) {
524
+ throw new Error('Each record must include Id for update');
525
+ }
526
+ }
527
+ const normalized = records.map((r) => ({ ...r, Id: r.Id ?? r.id }));
528
+ const result = await runBulkIngestOperation({
529
+ objectType: object_type,
530
+ operation: 'update',
531
+ records: normalized,
532
+ batch_size: batch_size ?? 200,
533
+ });
534
+ return text(result);
535
+ },
536
+ },
537
+ {
538
+ name: 'bulk_upsert_records',
539
+ description:
540
+ 'Creates new records or updates existing ones by matching an external ID field you specify, for sync and imports when you may not know if rows already exist. Requires an external ID field on the target object.',
541
+ inputSchema: {
542
+ type: 'object',
543
+ properties: {
544
+ object_type: { type: 'string' },
545
+ external_id_field: { type: 'string', description: 'External ID field API name' },
546
+ records: { type: 'array' },
547
+ batch_size: { type: 'number', description: 'Default 200' },
548
+ },
549
+ required: ['object_type', 'external_id_field', 'records'],
550
+ },
551
+ handler: async ({ object_type, external_id_field, records, batch_size }) => {
552
+ const result = await runBulkIngestOperation({
553
+ objectType: object_type,
554
+ operation: 'upsert',
555
+ externalIdFieldName: external_id_field,
556
+ records,
557
+ batch_size: batch_size ?? 200,
558
+ });
559
+ return text(result);
560
+ },
561
+ },
562
+ {
563
+ name: 'bulk_delete_records',
564
+ description:
565
+ 'Permanently deletes multiple Salesforce records in a single batch operation, requiring only the Id field for each record to be deleted. WARNING: This operation is irreversible and bypasses the recycle bin, so use with extreme caution and ensure you have proper backups.',
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ object_type: { type: 'string', description: 'SObject API name' },
570
+ record_ids: {
571
+ type: 'array',
572
+ items: { type: 'string' },
573
+ description: 'Salesforce record Ids to delete',
574
+ },
575
+ },
576
+ required: ['object_type', 'record_ids'],
577
+ },
578
+ handler: async ({ object_type, record_ids }) => {
579
+ const result = await runBulkIngestOperation({
580
+ objectType: object_type,
581
+ operation: 'delete',
582
+ recordIds: record_ids,
583
+ batch_size: 200,
584
+ });
585
+ return text(result);
586
+ },
587
+ },
588
+ {
589
+ name: 'get_bulk_job_status',
590
+ description:
591
+ 'Retrieves the current status and progress of a running bulk ingest job, returning the job state (InProgress/JobComplete/Failed), number of records processed, and count of failed records. Use this to monitor long-running bulk operations and determine when results are ready for retrieval.',
592
+ inputSchema: {
593
+ type: 'object',
594
+ properties: {
595
+ job_id: { type: 'string', description: 'Bulk ingest job Id' },
596
+ },
597
+ required: ['job_id'],
598
+ },
599
+ handler: async ({ job_id }) => {
600
+ await ensureAccessToken();
601
+ const base = getBaseUrl();
602
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
603
+ try {
604
+ const res = await axios.get(`${base}/jobs/ingest/${encodeURIComponent(job_id)}`, {
605
+ headers,
606
+ timeout: 120000,
607
+ validateStatus: () => true,
608
+ });
609
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
610
+ return text(res.data);
611
+ } catch (e) {
612
+ handleError(e);
613
+ }
614
+ },
615
+ },
616
+ {
617
+ name: 'get_bulk_job_results',
618
+ description:
619
+ 'Downloads the detailed results of a completed bulk ingest job, including success/failure status for each record and specific error messages for failed records. Use this after a bulk job completes to identify which records failed and why, enabling you to correct and retry failed records.',
620
+ inputSchema: {
621
+ type: 'object',
622
+ properties: {
623
+ job_id: { type: 'string' },
624
+ result_type: {
625
+ type: 'string',
626
+ enum: ['successfulResults', 'failedResults', 'unprocessedRecords'],
627
+ description:
628
+ 'successfulResults | failedResults | unprocessedRecords (CSV payload)',
629
+ },
630
+ },
631
+ required: ['job_id', 'result_type'],
632
+ },
633
+ handler: async ({ job_id, result_type }) => {
634
+ const map = {
635
+ successfulResults: 'successfulResults',
636
+ failedResults: 'failedResults',
637
+ unprocessedRecords: 'unprocessedrecords',
638
+ };
639
+ const suffix = map[result_type];
640
+ if (!suffix) throw new Error('Invalid result_type');
641
+ await ensureAccessToken();
642
+ const base = getBaseUrl();
643
+ const headers = { ...buildAuthHeaders(), Accept: 'text/csv' };
644
+ const url = `${base}/jobs/ingest/${encodeURIComponent(job_id)}/${suffix}`;
645
+ try {
646
+ const res = await axios.get(url, {
647
+ headers,
648
+ responseType: 'text',
649
+ timeout: 120000,
650
+ validateStatus: () => true,
651
+ });
652
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
653
+ return text({
654
+ job_id,
655
+ result_type,
656
+ format: 'text/csv',
657
+ body: res.data ?? '',
658
+ });
659
+ } catch (e) {
660
+ handleError(e);
661
+ }
662
+ },
663
+ },
664
+ ];
665
+ }
666
+
667
+ const SKIP_BULK_PATH_RE =
668
+ /\/oauth|\/token|\/login|\/logout|\/services\/oauth2|\/webhook|\/Soap\/|\/soap\//i;
669
+
670
+ function bulkVarMap(collection) {
671
+ const m = {};
672
+ for (const v of collection.variable || []) {
673
+ m[v.key] = v.value ?? '';
674
+ }
675
+ return m;
676
+ }
677
+
678
+ function bulkResolve(str, map) {
679
+ if (str == null) return str;
680
+ let out = String(str);
681
+ for (const [k, v] of Object.entries(map)) {
682
+ out = out.split(`{{${k}}}`).join(v ?? '');
683
+ }
684
+ return out;
685
+ }
686
+
687
+ function bulkInferType(value) {
688
+ if (value == null) return 'string';
689
+ const v = String(value).toLowerCase().trim();
690
+ if (v === '<integer>' || v === '<number>') return 'number';
691
+ if (v === '<boolean>') return 'boolean';
692
+ if (!Number.isNaN(Number(v)) && v !== '') return 'number';
693
+ if (v === 'true' || v === 'false') return 'boolean';
694
+ return 'string';
695
+ }
696
+
697
+ function bulkDesc(req, itemName) {
698
+ const d = req.description;
699
+ if (typeof d === 'string') return d.slice(0, 400);
700
+ if (d && typeof d === 'object' && d.content) return String(d.content).slice(0, 400);
701
+ return itemName;
702
+ }
703
+
704
+ function bulkVersionedPath(req, varMap) {
705
+ const raw = typeof req.url === 'string' ? req.url : req.url?.raw || '';
706
+ const resolved = bulkResolve(raw, varMap);
707
+ const [pathPart, qs] = resolved.split('?');
708
+ if (SKIP_BULK_PATH_RE.test(pathPart)) return null;
709
+ const noHost = pathPart.replace(/^https?:\/\/[^/?#]+/i, '');
710
+ if (!noHost.startsWith('/services/data/')) return null;
711
+ const after = noHost.slice('/services/data/'.length);
712
+ const m = after.match(/^(v[0-9.]+)\/(.+)$/);
713
+ if (!m) return null;
714
+ let path = `/${m[2]}`;
715
+ path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
716
+ path = path.replace(/\{\{_jobId\}\}/g, '{jobId}');
717
+ if (qs) path = `${path}?${qs}`;
718
+ return path;
719
+ }
720
+
721
+ function bulkBuildSchema(req, normalizedPath) {
722
+ const properties = {};
723
+ const required = [];
724
+
725
+ const pathNoQuery = normalizedPath.split('?')[0];
726
+ for (const [, name] of pathNoQuery.matchAll(/\{([^}]+)\}/g)) {
727
+ properties[name] = { type: 'string', description: `Path: ${name}` };
728
+ required.push(name);
729
+ }
730
+
731
+ const seen = new Set();
732
+ if (typeof req.url === 'object' && req.url?.query) {
733
+ for (const param of req.url.query) {
734
+ if (!param.key || param.disabled || seen.has(param.key)) continue;
735
+ seen.add(param.key);
736
+ if (properties[param.key]) continue;
737
+ properties[param.key] = {
738
+ type: bulkInferType(param.value),
739
+ description: param.description || `Query: ${param.key}`,
740
+ };
741
+ }
742
+ }
743
+
744
+ if (req.body?.mode === 'raw' && req.body.raw) {
745
+ try {
746
+ const bodyJson = JSON.parse(req.body.raw);
747
+ for (const [key, value] of Object.entries(bodyJson)) {
748
+ if (properties[key]) continue;
749
+ properties[key] = { type: bulkInferType(String(value)), description: `Body: ${key}` };
750
+ }
751
+ } catch {
752
+ /* non-JSON */
753
+ }
754
+ }
755
+
756
+ const isCsvPut =
757
+ req.method?.toUpperCase() === 'PUT' && pathNoQuery.includes('/jobs/ingest/') && pathNoQuery.endsWith('/batches');
758
+ if (isCsvPut) {
759
+ properties.csv_data = {
760
+ type: 'string',
761
+ description: 'CSV file contents (UTF-8) for this batch upload',
762
+ };
763
+ required.push('csv_data');
764
+ }
765
+
766
+ return { type: 'object', properties, required: [...new Set(required)] };
767
+ }
768
+
769
+ /** Like bulkBuildSchema plus Postman `formdata` fields (Connect file uploads). */
770
+ function connectBuildSchema(req, normalizedPath) {
771
+ const base = bulkBuildSchema(req, normalizedPath);
772
+ if (req.body?.mode === 'formdata' && req.body.formdata) {
773
+ for (const item of req.body.formdata) {
774
+ if (!item.key || base.properties[item.key]) continue;
775
+ base.properties[item.key] = {
776
+ type: 'string',
777
+ description: `Multipart field “${item.key}” (${item.type || 'text'}). For files, pass base64 or use upload_file_to_record.`,
778
+ };
779
+ }
780
+ }
781
+ return base;
782
+ }
783
+
784
+ /** Postman `graphql` body → MCP tool schema (query + optional variables). */
785
+ function graphqlBuildSchema(req) {
786
+ const g = req.body?.mode === 'graphql' ? req.body.graphql : null;
787
+ const exampleQuery = typeof g?.query === 'string' ? g.query : '';
788
+ let exampleVars;
789
+ if (g?.variables != null && String(g.variables).trim() !== '') {
790
+ try {
791
+ exampleVars = typeof g.variables === 'string' ? JSON.parse(g.variables) : g.variables;
792
+ } catch {
793
+ exampleVars = undefined;
794
+ }
795
+ }
796
+ const properties = {
797
+ query: {
798
+ type: 'string',
799
+ description: exampleQuery
800
+ ? 'GraphQL query string (Postman example provided as default).'
801
+ : 'GraphQL query string.',
802
+ ...(exampleQuery ? { default: exampleQuery } : {}),
803
+ },
804
+ variables: {
805
+ type: 'object',
806
+ description: 'Optional GraphQL variables object.',
807
+ additionalProperties: true,
808
+ ...(exampleVars && typeof exampleVars === 'object' ? { default: exampleVars } : {}),
809
+ },
810
+ };
811
+ return { type: 'object', properties, required: ['query'] };
812
+ }
813
+
814
+ /**
815
+ * Cursor MCP enforces combined `user-salesforce-adoptai:<tool>` length ≤ 60 (incl. colon).
816
+ * With a 23-char server id, tool names must be ≤ 36 chars.
817
+ */
818
+ const MCP_TOOL_NAME_MAX_LEN = 60 - String('user-salesforce-adoptai').length - 1;
819
+
820
+ /**
821
+ * Safe identifier from a Postman item name. Shortens to maxSlice and appends a stable
822
+ * hash suffix when truncated so collisions stay rare.
823
+ */
824
+ function bulkSlug(name, maxSlice = MCP_TOOL_NAME_MAX_LEN) {
825
+ let s = String(name || '')
826
+ .replace(/[^a-zA-Z0-9_]+/g, '_')
827
+ .replace(/^_+|_+$/g, '')
828
+ .toLowerCase();
829
+ if (s.length <= maxSlice) return s;
830
+ const h = createHash('sha256').update(String(name)).digest('hex').slice(0, 5);
831
+ const keep = Math.max(1, maxSlice - 6);
832
+ const head = s.slice(0, keep).replace(/_+$/g, '');
833
+ return `${head}_${h}`;
834
+ }
835
+
836
+ function bulkExtractRequests(items, varMap, acc) {
837
+ for (const item of items || []) {
838
+ if (Array.isArray(item.item)) {
839
+ bulkExtractRequests(item.item, varMap, acc);
840
+ } else if (item.request) {
841
+ const req = item.request;
842
+ const method = String(req.method || 'GET').toUpperCase();
843
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
844
+
845
+ const path = bulkVersionedPath(req, varMap);
846
+ if (!path) continue;
847
+
848
+ const pathKey = path.split('?')[0];
849
+ const dedupeKey = `${method}|${pathKey}`;
850
+ const name = `bulk_v2_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'bulk_v2_'.length)}`;
851
+
852
+ acc.push({
853
+ dedupeKey,
854
+ name,
855
+ description: bulkDesc(req, item.name),
856
+ method,
857
+ path,
858
+ inputSchema: bulkBuildSchema(req, path.split('?')[0]),
859
+ });
860
+ }
861
+ }
862
+ }
863
+
864
+ function bulkV2ManifestEndpoints() {
865
+ if (!existsSync(BULK_V2_POSTMAN)) return [];
866
+ const col = JSON.parse(readFileSync(BULK_V2_POSTMAN, 'utf8'));
867
+ const varMap = bulkVarMap(col);
868
+ const raw = [];
869
+ bulkExtractRequests(col.item, varMap, raw);
870
+
871
+ const seen = new Set();
872
+ const endpoints = [];
873
+ for (const ep of raw) {
874
+ if (seen.has(ep.dedupeKey)) continue;
875
+ seen.add(ep.dedupeKey);
876
+ const { dedupeKey: _d, ...rest } = ep;
877
+ endpoints.push(rest);
878
+ }
879
+ return endpoints;
880
+ }
881
+
882
+ function connectExtractRequests(items, varMap, acc) {
883
+ for (const item of items || []) {
884
+ if (Array.isArray(item.item)) {
885
+ connectExtractRequests(item.item, varMap, acc);
886
+ } else if (item.request) {
887
+ const req = item.request;
888
+ const method = String(req.method || 'GET').toUpperCase();
889
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
890
+
891
+ const path = bulkVersionedPath(req, varMap);
892
+ if (!path) continue;
893
+
894
+ const pathKey = path.split('?')[0];
895
+ const dedupeKey = `${method}|${pathKey}`;
896
+ const name = `connect_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'connect_'.length)}`;
897
+
898
+ acc.push({
899
+ dedupeKey,
900
+ name,
901
+ description: bulkDesc(req, item.name),
902
+ method,
903
+ path,
904
+ inputSchema: connectBuildSchema(req, path.split('?')[0]),
905
+ });
906
+ }
907
+ }
908
+ }
909
+
910
+ function connectManifestEndpoints() {
911
+ if (!existsSync(CONNECT_POSTMAN)) return [];
912
+ const col = JSON.parse(readFileSync(CONNECT_POSTMAN, 'utf8'));
913
+ const varMap = bulkVarMap(col);
914
+ const raw = [];
915
+ connectExtractRequests(col.item, varMap, raw);
916
+
917
+ const seen = new Set();
918
+ const endpoints = [];
919
+ for (const ep of raw) {
920
+ if (seen.has(ep.dedupeKey)) continue;
921
+ seen.add(ep.dedupeKey);
922
+ const { dedupeKey: _d, ...rest } = ep;
923
+ endpoints.push(rest);
924
+ }
925
+ return endpoints;
926
+ }
927
+
928
+ function connectGeneratedToolsFromPostman() {
929
+ const endpoints = connectManifestEndpoints();
930
+ const toolsOut = [];
931
+
932
+ for (const ep of endpoints) {
933
+ toolsOut.push({
934
+ name: ep.name,
935
+ description: ep.description,
936
+ method: ep.method,
937
+ path: ep.path,
938
+ urlKind: 'versioned',
939
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
940
+ handler: async (params) => {
941
+ const [pbase, pq] = ep.path.split('?');
942
+ const merged = { ...params };
943
+ if (pq) {
944
+ for (const [k, v] of new URLSearchParams(pq)) {
945
+ if (merged[k] === undefined) merged[k] = v;
946
+ }
947
+ }
948
+ const data = await sfRequest(ep.method, pbase, 'versioned', merged);
949
+ return text(data);
950
+ },
951
+ });
952
+ }
953
+
954
+ toolsOut.sort((a, b) => {
955
+ const ea = endpoints.find((e) => e.name === a.name);
956
+ const eb = endpoints.find((e) => e.name === b.name);
957
+ const ra = methodRank(ea?.method);
958
+ const rb = methodRank(eb?.method);
959
+ if (ra !== rb) return ra - rb;
960
+ return a.name.localeCompare(b.name);
961
+ });
962
+
963
+ return toolsOut;
964
+ }
965
+
966
+ /** Path under `/tooling` (leading slash), e.g. `/query/?q=...`, from Postman `{_endpoint}/services/data/v{{version}}/tooling/...`. */
967
+ function toolingVersionedPath(req, varMap) {
968
+ const full = bulkVersionedPath(req, varMap);
969
+ if (!full) return null;
970
+ const [pathPart, qs] = full.split('?');
971
+ if (!pathPart.startsWith('/tooling/') && pathPart !== '/tooling') return null;
972
+ const rel = pathPart === '/tooling' ? '' : pathPart.slice('/tooling'.length);
973
+ return qs != null && qs !== '' ? `${rel}?${qs}` : rel;
974
+ }
975
+
976
+ function toolingExtractRequests(items, varMap, acc) {
977
+ for (const item of items || []) {
978
+ if (Array.isArray(item.item)) {
979
+ toolingExtractRequests(item.item, varMap, acc);
980
+ } else if (item.request) {
981
+ const req = item.request;
982
+ const method = String(req.method || 'GET').toUpperCase();
983
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
984
+
985
+ const path = toolingVersionedPath(req, varMap);
986
+ if (!path) continue;
987
+
988
+ const pathKey = path.split('?')[0];
989
+ const dedupeKey = `${method}|${pathKey}`;
990
+ const name = `tooling_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'tooling_'.length)}`;
991
+
992
+ acc.push({
993
+ dedupeKey,
994
+ name,
995
+ description: bulkDesc(req, item.name),
996
+ method,
997
+ path,
998
+ inputSchema: connectBuildSchema(req, path.split('?')[0] || '/'),
999
+ });
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ function toolingManifestEndpoints() {
1005
+ if (!existsSync(TOOLING_POSTMAN)) return [];
1006
+ const col = JSON.parse(readFileSync(TOOLING_POSTMAN, 'utf8'));
1007
+ const varMap = bulkVarMap(col);
1008
+ const raw = [];
1009
+ toolingExtractRequests(col.item, varMap, raw);
1010
+
1011
+ const seen = new Set();
1012
+ const endpoints = [];
1013
+ for (const ep of raw) {
1014
+ if (seen.has(ep.dedupeKey)) continue;
1015
+ seen.add(ep.dedupeKey);
1016
+ const { dedupeKey: _d, ...rest } = ep;
1017
+ endpoints.push(rest);
1018
+ }
1019
+ return endpoints;
1020
+ }
1021
+
1022
+ function toolingGeneratedToolsFromPostman() {
1023
+ const endpoints = toolingManifestEndpoints();
1024
+ const toolsOut = [];
1025
+
1026
+ for (const ep of endpoints) {
1027
+ toolsOut.push({
1028
+ name: ep.name,
1029
+ description: ep.description,
1030
+ method: ep.method,
1031
+ path: ep.path,
1032
+ urlKind: 'tooling',
1033
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1034
+ handler: async (params) => {
1035
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1036
+ const [pbase, pq] = pathWithQ.split('?');
1037
+ const merged = { ...params };
1038
+ if (pq) {
1039
+ for (const [k, v] of new URLSearchParams(pq)) {
1040
+ if (merged[k] === undefined) merged[k] = v;
1041
+ }
1042
+ }
1043
+ const data = await sfRequest(ep.method, pbase, 'tooling', merged);
1044
+ return text(data);
1045
+ },
1046
+ });
1047
+ }
1048
+
1049
+ toolsOut.sort((a, b) => {
1050
+ const ea = endpoints.find((e) => e.name === a.name);
1051
+ const eb = endpoints.find((e) => e.name === b.name);
1052
+ const ra = methodRank(ea?.method);
1053
+ const rb = methodRank(eb?.method);
1054
+ if (ra !== rb) return ra - rb;
1055
+ return a.name.localeCompare(b.name);
1056
+ });
1057
+
1058
+ return toolsOut;
1059
+ }
1060
+
1061
+ /** Path under `/ui-api` from Postman `{_endpoint}/services/data/v{{version}}/ui-api/...`. */
1062
+ function uiVersionedPath(req, varMap) {
1063
+ const full = bulkVersionedPath(req, varMap);
1064
+ if (!full) return null;
1065
+ const [pathPart, qs] = full.split('?');
1066
+ if (!pathPart.startsWith('/ui-api/') && pathPart !== '/ui-api') return null;
1067
+ const rel = pathPart === '/ui-api' ? '' : pathPart.slice('/ui-api'.length);
1068
+ return qs != null && qs !== '' ? `${rel}?${qs}` : rel;
1069
+ }
1070
+
1071
+ function uiExtractRequests(items, varMap, acc) {
1072
+ for (const item of items || []) {
1073
+ if (Array.isArray(item.item)) {
1074
+ uiExtractRequests(item.item, varMap, acc);
1075
+ } else if (item.request) {
1076
+ const req = item.request;
1077
+ const method = String(req.method || 'GET').toUpperCase();
1078
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1079
+
1080
+ const path = uiVersionedPath(req, varMap);
1081
+ if (!path) continue;
1082
+
1083
+ const pathKey = path.split('?')[0];
1084
+ const dedupeKey = `${method}|${pathKey}`;
1085
+ const name = `ui_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'ui_'.length)}`;
1086
+
1087
+ acc.push({
1088
+ dedupeKey,
1089
+ name,
1090
+ description: bulkDesc(req, item.name),
1091
+ method,
1092
+ path,
1093
+ inputSchema: connectBuildSchema(req, path.split('?')[0] || '/'),
1094
+ });
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ function uiManifestEndpoints() {
1100
+ if (!existsSync(UI_POSTMAN)) return [];
1101
+ const col = JSON.parse(readFileSync(UI_POSTMAN, 'utf8'));
1102
+ const varMap = bulkVarMap(col);
1103
+ const raw = [];
1104
+ uiExtractRequests(col.item, varMap, raw);
1105
+
1106
+ const seen = new Set();
1107
+ const endpoints = [];
1108
+ for (const ep of raw) {
1109
+ if (seen.has(ep.dedupeKey)) continue;
1110
+ seen.add(ep.dedupeKey);
1111
+ const { dedupeKey: _d, ...rest } = ep;
1112
+ endpoints.push(rest);
1113
+ }
1114
+ return endpoints;
1115
+ }
1116
+
1117
+ function uiGeneratedToolsFromPostman() {
1118
+ const endpoints = uiManifestEndpoints();
1119
+ const toolsOut = [];
1120
+
1121
+ for (const ep of endpoints) {
1122
+ toolsOut.push({
1123
+ name: ep.name,
1124
+ description: ep.description,
1125
+ method: ep.method,
1126
+ path: ep.path,
1127
+ urlKind: 'ui-api',
1128
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1129
+ handler: async (params) => {
1130
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1131
+ const [pbase, pq] = pathWithQ.split('?');
1132
+ const merged = { ...params };
1133
+ if (pq) {
1134
+ for (const [k, v] of new URLSearchParams(pq)) {
1135
+ if (merged[k] === undefined) merged[k] = v;
1136
+ }
1137
+ }
1138
+ const data = await sfRequest(ep.method, pbase, 'ui-api', merged);
1139
+ return text(data);
1140
+ },
1141
+ });
1142
+ }
1143
+
1144
+ toolsOut.sort((a, b) => {
1145
+ const ea = endpoints.find((e) => e.name === a.name);
1146
+ const eb = endpoints.find((e) => e.name === b.name);
1147
+ const ra = methodRank(ea?.method);
1148
+ const rb = methodRank(eb?.method);
1149
+ if (ra !== rb) return ra - rb;
1150
+ return a.name.localeCompare(b.name);
1151
+ });
1152
+
1153
+ return toolsOut;
1154
+ }
1155
+
1156
+ /** Smart Data Discovery paths: `/services/data/v…/smartdatadiscovery/…` (same base as `getBaseUrl`). */
1157
+ function einsteinPsVersionedPath(req, varMap) {
1158
+ const full = bulkVersionedPath(req, varMap);
1159
+ if (!full) return null;
1160
+ const pathPart = full.split('?')[0];
1161
+ if (!pathPart.startsWith('/smartdatadiscovery/') && pathPart !== '/smartdatadiscovery') return null;
1162
+ return full;
1163
+ }
1164
+
1165
+ function einsteinPsExtractRequests(items, varMap, acc) {
1166
+ for (const item of items || []) {
1167
+ if (Array.isArray(item.item)) {
1168
+ einsteinPsExtractRequests(item.item, varMap, acc);
1169
+ } else if (item.request) {
1170
+ const req = item.request;
1171
+ const method = String(req.method || 'GET').toUpperCase();
1172
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1173
+
1174
+ const path = einsteinPsVersionedPath(req, varMap);
1175
+ if (!path) continue;
1176
+
1177
+ const pathKey = path.split('?')[0];
1178
+ const dedupeKey = `${method}|${pathKey}`;
1179
+ const name = `einstein_ps_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'einstein_ps_'.length)}`;
1180
+
1181
+ acc.push({
1182
+ dedupeKey,
1183
+ name,
1184
+ description: bulkDesc(req, item.name),
1185
+ method,
1186
+ path,
1187
+ inputSchema: connectBuildSchema(req, path.split('?')[0] || '/'),
1188
+ });
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ function einsteinPsManifestEndpoints() {
1194
+ if (!existsSync(EINSTEIN_PS_POSTMAN)) return [];
1195
+ const col = JSON.parse(readFileSync(EINSTEIN_PS_POSTMAN, 'utf8'));
1196
+ const varMap = bulkVarMap(col);
1197
+ const raw = [];
1198
+ einsteinPsExtractRequests(col.item, varMap, raw);
1199
+
1200
+ const seen = new Set();
1201
+ const endpoints = [];
1202
+ for (const ep of raw) {
1203
+ if (seen.has(ep.dedupeKey)) continue;
1204
+ seen.add(ep.dedupeKey);
1205
+ const { dedupeKey: _d, ...rest } = ep;
1206
+ endpoints.push(rest);
1207
+ }
1208
+ return endpoints;
1209
+ }
1210
+
1211
+ function einsteinPsGeneratedToolsFromPostman() {
1212
+ const endpoints = einsteinPsManifestEndpoints();
1213
+ const toolsOut = [];
1214
+
1215
+ for (const ep of endpoints) {
1216
+ toolsOut.push({
1217
+ name: ep.name,
1218
+ description: ep.description,
1219
+ method: ep.method,
1220
+ path: ep.path,
1221
+ urlKind: 'versioned',
1222
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1223
+ handler: async (params) => {
1224
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1225
+ const [pbase, pq] = pathWithQ.split('?');
1226
+ const merged = { ...params };
1227
+ if (pq) {
1228
+ for (const [k, v] of new URLSearchParams(pq)) {
1229
+ if (merged[k] === undefined) merged[k] = v;
1230
+ }
1231
+ }
1232
+ const data = await sfRequest(ep.method, pbase, 'versioned', merged);
1233
+ return text(data);
1234
+ },
1235
+ });
1236
+ }
1237
+
1238
+ toolsOut.sort((a, b) => {
1239
+ const ea = endpoints.find((e) => e.name === a.name);
1240
+ const eb = endpoints.find((e) => e.name === b.name);
1241
+ const ra = methodRank(ea?.method);
1242
+ const rb = methodRank(eb?.method);
1243
+ if (ra !== rb) return ra - rb;
1244
+ return a.name.localeCompare(b.name);
1245
+ });
1246
+
1247
+ return toolsOut;
1248
+ }
1249
+
1250
+ /** Event Platform: Tooling-relative paths vs standard `/services/data/v…` paths. */
1251
+ function eventPlatformPathAndKind(req, varMap) {
1252
+ const full = bulkVersionedPath(req, varMap);
1253
+ if (!full) return null;
1254
+ const [pathPart, qs] = full.split('?');
1255
+ if (SKIP_BULK_PATH_RE.test(pathPart)) return null;
1256
+ const qstr = qs !== undefined && qs !== '' ? `?${qs}` : '';
1257
+ if (pathPart.startsWith('/tooling/') || pathPart === '/tooling') {
1258
+ const rel = pathPart === '/tooling' ? '' : pathPart.slice('/tooling'.length);
1259
+ return { path: rel + qstr, urlKind: 'tooling' };
1260
+ }
1261
+ return { path: pathPart + qstr, urlKind: 'versioned' };
1262
+ }
1263
+
1264
+ function eventPlatformExtractRequests(items, varMap, acc) {
1265
+ for (const item of items || []) {
1266
+ if (Array.isArray(item.item)) {
1267
+ eventPlatformExtractRequests(item.item, varMap, acc);
1268
+ } else if (item.request) {
1269
+ const req = item.request;
1270
+ const method = String(req.method || 'GET').toUpperCase();
1271
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1272
+
1273
+ const pk = eventPlatformPathAndKind(req, varMap);
1274
+ if (!pk) continue;
1275
+
1276
+ const pathKey = pk.path.split('?')[0];
1277
+ const dedupeKey = `${method}|${pathKey}|${pk.urlKind}`;
1278
+ const name = `evt_pf_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'evt_pf_'.length)}`;
1279
+
1280
+ acc.push({
1281
+ dedupeKey,
1282
+ name,
1283
+ description: bulkDesc(req, item.name),
1284
+ method,
1285
+ path: pk.path,
1286
+ urlKind: pk.urlKind,
1287
+ inputSchema: connectBuildSchema(req, pathKey || '/'),
1288
+ });
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ function eventPlatformManifestEndpoints() {
1294
+ if (!existsSync(EVENT_PLATFORM_POSTMAN)) return [];
1295
+ const col = JSON.parse(readFileSync(EVENT_PLATFORM_POSTMAN, 'utf8'));
1296
+ const varMap = bulkVarMap(col);
1297
+ const raw = [];
1298
+ eventPlatformExtractRequests(col.item, varMap, raw);
1299
+
1300
+ const seen = new Set();
1301
+ const endpoints = [];
1302
+ for (const ep of raw) {
1303
+ if (seen.has(ep.dedupeKey)) continue;
1304
+ seen.add(ep.dedupeKey);
1305
+ const { dedupeKey: _d, ...rest } = ep;
1306
+ endpoints.push(rest);
1307
+ }
1308
+ return endpoints;
1309
+ }
1310
+
1311
+ function eventPlatformGeneratedToolsFromPostman() {
1312
+ const endpoints = eventPlatformManifestEndpoints();
1313
+ const toolsOut = [];
1314
+
1315
+ for (const ep of endpoints) {
1316
+ toolsOut.push({
1317
+ name: ep.name,
1318
+ description: ep.description,
1319
+ method: ep.method,
1320
+ path: ep.path,
1321
+ urlKind: ep.urlKind,
1322
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1323
+ handler: async (params) => {
1324
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1325
+ const [pbase, pq] = pathWithQ.split('?');
1326
+ const merged = { ...params };
1327
+ if (pq) {
1328
+ for (const [k, v] of new URLSearchParams(pq)) {
1329
+ if (merged[k] === undefined) merged[k] = v;
1330
+ }
1331
+ }
1332
+ const data = await sfRequest(ep.method, pbase, ep.urlKind, merged);
1333
+ return text(data);
1334
+ },
1335
+ });
1336
+ }
1337
+
1338
+ toolsOut.sort((a, b) => {
1339
+ const ea = endpoints.find((e) => e.name === a.name);
1340
+ const eb = endpoints.find((e) => e.name === b.name);
1341
+ const ra = methodRank(ea?.method);
1342
+ const rb = methodRank(eb?.method);
1343
+ if (ra !== rb) return ra - rb;
1344
+ return a.name.localeCompare(b.name);
1345
+ });
1346
+
1347
+ return toolsOut;
1348
+ }
1349
+
1350
+ function graphqlExtractRequests(items, varMap, acc) {
1351
+ for (const item of items || []) {
1352
+ if (Array.isArray(item.item)) {
1353
+ graphqlExtractRequests(item.item, varMap, acc);
1354
+ } else if (item.request) {
1355
+ const req = item.request;
1356
+ const method = String(req.method || 'GET').toUpperCase();
1357
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1358
+
1359
+ const path = bulkVersionedPath(req, varMap);
1360
+ if (!path || path.split('?')[0] !== '/graphql') continue;
1361
+
1362
+ const slug = bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'gql_'.length);
1363
+ const dedupeKey = `${method}|/graphql|${slug}`;
1364
+ acc.push({
1365
+ dedupeKey,
1366
+ name: `gql_${slug}`,
1367
+ description: bulkDesc(req, item.name) || item.name,
1368
+ method,
1369
+ inputSchema: graphqlBuildSchema(req),
1370
+ });
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ function graphqlManifestEndpoints() {
1376
+ if (!existsSync(GRAPHQL_POSTMAN)) return [];
1377
+ const col = JSON.parse(readFileSync(GRAPHQL_POSTMAN, 'utf8'));
1378
+ const varMap = bulkVarMap(col);
1379
+ const raw = [];
1380
+ graphqlExtractRequests(col.item, varMap, raw);
1381
+
1382
+ const seen = new Set();
1383
+ const endpoints = [];
1384
+ for (const ep of raw) {
1385
+ if (seen.has(ep.dedupeKey)) continue;
1386
+ seen.add(ep.dedupeKey);
1387
+ const { dedupeKey: _d, ...rest } = ep;
1388
+ endpoints.push(rest);
1389
+ }
1390
+ return endpoints;
1391
+ }
1392
+
1393
+ function graphqlGeneratedToolsFromPostman() {
1394
+ const endpoints = graphqlManifestEndpoints();
1395
+ const toolsOut = [];
1396
+
1397
+ for (const ep of endpoints) {
1398
+ toolsOut.push({
1399
+ name: ep.name,
1400
+ description: ep.description,
1401
+ method: ep.method,
1402
+ path: '',
1403
+ urlKind: 'graphql',
1404
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1405
+ handler: async (params) => {
1406
+ const body = { query: params.query };
1407
+ if (
1408
+ params.variables != null &&
1409
+ typeof params.variables === 'object' &&
1410
+ !Array.isArray(params.variables) &&
1411
+ Object.keys(params.variables).length > 0
1412
+ ) {
1413
+ body.variables = params.variables;
1414
+ }
1415
+ const data = await sfRequest(ep.method, '/', 'graphql', { _rawBody: body });
1416
+ return text(data);
1417
+ },
1418
+ });
1419
+ }
1420
+
1421
+ toolsOut.sort((a, b) => {
1422
+ const ea = endpoints.find((e) => e.name === a.name);
1423
+ const eb = endpoints.find((e) => e.name === b.name);
1424
+ const ra = methodRank(ea?.method);
1425
+ const rb = methodRank(eb?.method);
1426
+ if (ra !== rb) return ra - rb;
1427
+ return a.name.localeCompare(b.name);
1428
+ });
1429
+
1430
+ return toolsOut;
1431
+ }
1432
+
1433
+ function subMgmtExtractRequests(items, varMap, acc) {
1434
+ for (const item of items || []) {
1435
+ if (Array.isArray(item.item)) {
1436
+ subMgmtExtractRequests(item.item, varMap, acc);
1437
+ } else if (item.request) {
1438
+ const req = item.request;
1439
+ const method = String(req.method || 'GET').toUpperCase();
1440
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1441
+
1442
+ const path = bulkVersionedPath(req, varMap);
1443
+ if (!path) continue;
1444
+ const pathPart = path.split('?')[0];
1445
+ if (SKIP_BULK_PATH_RE.test(pathPart)) continue;
1446
+
1447
+ const slug = bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'sub_mgmt_'.length);
1448
+ const dedupeKey = `${method}|${path}|${slug}`;
1449
+ acc.push({
1450
+ dedupeKey,
1451
+ name: `sub_mgmt_${slug}`,
1452
+ description: bulkDesc(req, item.name) || item.name,
1453
+ method,
1454
+ path,
1455
+ inputSchema: connectBuildSchema(req, pathPart || '/'),
1456
+ });
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ function subMgmtManifestEndpoints() {
1462
+ if (!existsSync(SUBSCRIPTION_MGMT_POSTMAN)) return [];
1463
+ const col = JSON.parse(readFileSync(SUBSCRIPTION_MGMT_POSTMAN, 'utf8'));
1464
+ const varMap = bulkVarMap(col);
1465
+ const raw = [];
1466
+ subMgmtExtractRequests(col.item, varMap, raw);
1467
+
1468
+ const seen = new Set();
1469
+ const endpoints = [];
1470
+ for (const ep of raw) {
1471
+ if (seen.has(ep.dedupeKey)) continue;
1472
+ seen.add(ep.dedupeKey);
1473
+ const { dedupeKey: _d, ...rest } = ep;
1474
+ endpoints.push(rest);
1475
+ }
1476
+ return endpoints;
1477
+ }
1478
+
1479
+ function subMgmtGeneratedToolsFromPostman() {
1480
+ const endpoints = subMgmtManifestEndpoints();
1481
+ const toolsOut = [];
1482
+
1483
+ for (const ep of endpoints) {
1484
+ toolsOut.push({
1485
+ name: ep.name,
1486
+ description: ep.description,
1487
+ method: ep.method,
1488
+ path: ep.path,
1489
+ urlKind: 'versioned',
1490
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1491
+ handler: async (params) => {
1492
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1493
+ const [pbase, pq] = pathWithQ.split('?');
1494
+ const merged = { ...params };
1495
+ if (pq) {
1496
+ for (const [k, v] of new URLSearchParams(pq)) {
1497
+ if (merged[k] === undefined) merged[k] = v;
1498
+ }
1499
+ }
1500
+ const data = await sfRequest(ep.method, pbase, 'versioned', merged);
1501
+ return text(data);
1502
+ },
1503
+ });
1504
+ }
1505
+
1506
+ toolsOut.sort((a, b) => {
1507
+ const ea = endpoints.find((e) => e.name === a.name);
1508
+ const eb = endpoints.find((e) => e.name === b.name);
1509
+ const ra = methodRank(ea?.method);
1510
+ const rb = methodRank(eb?.method);
1511
+ if (ra !== rb) return ra - rb;
1512
+ return a.name.localeCompare(b.name);
1513
+ });
1514
+
1515
+ return toolsOut;
1516
+ }
1517
+
1518
+ /** Apex REST or platform REST path for Industries Postman items. */
1519
+ function industriesPathAndKind(req, varMap) {
1520
+ const raw = typeof req.url === 'string' ? req.url : req.url?.raw || '';
1521
+ const resolved = bulkResolve(raw, varMap);
1522
+ const [pathPart, qs] = resolved.split('?');
1523
+ const noHost = pathPart.replace(/^https?:\/\/[^/?#]+/i, '');
1524
+
1525
+ if (noHost.startsWith('/services/apexrest')) {
1526
+ let after = noHost.slice('/services/apexrest'.length);
1527
+ if (!after.startsWith('/')) after = `/${after}`;
1528
+ const qstr = qs !== undefined && qs !== '' ? `?${qs}` : '';
1529
+ return { path: after + qstr, urlKind: 'apex' };
1530
+ }
1531
+
1532
+ const full = bulkVersionedPath(req, varMap);
1533
+ if (!full) return null;
1534
+ const pp = full.split('?')[0];
1535
+ if (SKIP_BULK_PATH_RE.test(pp)) return null;
1536
+ return { path: full, urlKind: 'versioned' };
1537
+ }
1538
+
1539
+ function industriesUseCasePrefix(trail, itemName, pk) {
1540
+ const blob = [...trail, itemName || ''].join(' ').toLowerCase();
1541
+ const pathLow = (pk.path || '').toLowerCase();
1542
+ const nameLow = (itemName || '').toLowerCase();
1543
+ if (
1544
+ pathLow.includes('sbqq') ||
1545
+ (pathLow.includes('servicerouter') &&
1546
+ /quote|config|contract|product|proposal|save|calculate|validate|read|amend|renew|add.product/i.test(nameLow + blob))
1547
+ ) {
1548
+ return 'CPQ / Revenue (configure-price-quote, contracts, product rules) — ';
1549
+ }
1550
+ if (
1551
+ /loyalty|member|voucher|promotion|points|enroll|ledger|journal|benefit|tier|game|redeem|cart|wallet/i.test(
1552
+ blob
1553
+ )
1554
+ ) {
1555
+ return 'Loyalty & programs (member lifecycle, offers, wallets) — ';
1556
+ }
1557
+ if (/integration procedure|omnistudio/i.test(blob) || /integration procedure/i.test(nameLow)) {
1558
+ return 'Omnistudio (Integration Procedures, guided flows) — ';
1559
+ }
1560
+ if (pk.urlKind === 'versioned') {
1561
+ return 'Industries REST (platform / data) — ';
1562
+ }
1563
+ return 'Salesforce Industries — ';
1564
+ }
1565
+
1566
+ function industriesExtractRequests(items, varMap, acc, trail = []) {
1567
+ for (const item of items || []) {
1568
+ if (Array.isArray(item.item)) {
1569
+ const label = item.name ? String(item.name) : '';
1570
+ industriesExtractRequests(item.item, varMap, acc, label ? [...trail, label] : trail);
1571
+ } else if (item.request) {
1572
+ const req = item.request;
1573
+ const method = String(req.method || 'GET').toUpperCase();
1574
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1575
+
1576
+ const pk = industriesPathAndKind(req, varMap);
1577
+ if (!pk) continue;
1578
+ const pathPart = pk.path.split('?')[0];
1579
+ if (SKIP_BULK_PATH_RE.test(pathPart)) continue;
1580
+
1581
+ const slug = bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'industries_'.length);
1582
+ const dedupeKey = `${method}|${pk.path}|${pk.urlKind}|${slug}`;
1583
+ const baseDesc = bulkDesc(req, item.name) || item.name;
1584
+ const breadcrumbs = trail.length ? ` Context: ${trail.join(' › ')}.` : '';
1585
+ const description = (
1586
+ industriesUseCasePrefix(trail, item.name, pk) +
1587
+ baseDesc +
1588
+ breadcrumbs
1589
+ ).slice(0, 500);
1590
+
1591
+ acc.push({
1592
+ dedupeKey,
1593
+ name: `industries_${slug}`,
1594
+ description,
1595
+ method,
1596
+ path: pk.path,
1597
+ urlKind: pk.urlKind,
1598
+ inputSchema: connectBuildSchema(req, pathPart || '/'),
1599
+ });
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ function industriesManifestEndpoints() {
1605
+ if (!existsSync(INDUSTRIES_POSTMAN)) return [];
1606
+ const col = JSON.parse(readFileSync(INDUSTRIES_POSTMAN, 'utf8'));
1607
+ const varMap = bulkVarMap(col);
1608
+ const raw = [];
1609
+ industriesExtractRequests(col.item, varMap, raw);
1610
+
1611
+ const seen = new Set();
1612
+ const endpoints = [];
1613
+ for (const ep of raw) {
1614
+ if (seen.has(ep.dedupeKey)) continue;
1615
+ seen.add(ep.dedupeKey);
1616
+ const { dedupeKey: _d, ...rest } = ep;
1617
+ endpoints.push(rest);
1618
+ }
1619
+ return endpoints;
1620
+ }
1621
+
1622
+ function industriesGeneratedToolsFromPostman() {
1623
+ const endpoints = industriesManifestEndpoints();
1624
+ const toolsOut = [];
1625
+
1626
+ for (const ep of endpoints) {
1627
+ toolsOut.push({
1628
+ name: ep.name,
1629
+ description: ep.description,
1630
+ method: ep.method,
1631
+ path: ep.path,
1632
+ urlKind: ep.urlKind,
1633
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1634
+ handler: async (params) => {
1635
+ const pathWithQ = ep.path.startsWith('/') ? ep.path : `/${ep.path}`;
1636
+ const [pbase, pq] = pathWithQ.split('?');
1637
+ const merged = { ...params };
1638
+ if (pq) {
1639
+ for (const [k, v] of new URLSearchParams(pq)) {
1640
+ if (merged[k] === undefined) merged[k] = v;
1641
+ }
1642
+ }
1643
+ const data = await sfRequest(ep.method, pbase, ep.urlKind, merged);
1644
+ return text(data);
1645
+ },
1646
+ });
1647
+ }
1648
+
1649
+ toolsOut.sort((a, b) => {
1650
+ const ea = endpoints.find((e) => e.name === a.name);
1651
+ const eb = endpoints.find((e) => e.name === b.name);
1652
+ const ra = methodRank(ea?.method);
1653
+ const rb = methodRank(eb?.method);
1654
+ if (ra !== rb) return ra - rb;
1655
+ return a.name.localeCompare(b.name);
1656
+ });
1657
+
1658
+ return toolsOut;
1659
+ }
1660
+
1661
+ function bulkV2IsCsvResultGet(method, path) {
1662
+ return (
1663
+ method === 'GET' &&
1664
+ /\/jobs\/ingest\/\{jobId\}\/(successfulResults|failedResults|unprocessedrecords)$/.test(
1665
+ path.split('?')[0]
1666
+ )
1667
+ );
1668
+ }
1669
+
1670
+ function bulkV2GeneratedTools() {
1671
+ const endpoints = bulkV2ManifestEndpoints();
1672
+ const toolsOut = [];
1673
+
1674
+ for (const ep of endpoints) {
1675
+ const pathNoQuery = ep.path.split('?')[0];
1676
+ const isCsvPut = ep.method === 'PUT' && pathNoQuery.endsWith('/batches');
1677
+ const isCsvGet = bulkV2IsCsvResultGet(ep.method, pathNoQuery);
1678
+
1679
+ if (isCsvPut) {
1680
+ toolsOut.push({
1681
+ name: ep.name,
1682
+ description: ep.description,
1683
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1684
+ handler: async (params) => {
1685
+ const basePath = ep.path.split('?')[0];
1686
+ const { pathValues, rest } = splitPathAndRest(basePath, params);
1687
+ const csv = rest.csv_data;
1688
+ if (csv == null) throw new Error('csv_data is required for batch upload');
1689
+ await ensureAccessToken();
1690
+ const url = getBaseUrl() + applyPathTemplate(basePath, pathValues);
1691
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'text/csv', Accept: 'application/json' };
1692
+ try {
1693
+ const res = await axios.put(url, csv, {
1694
+ headers,
1695
+ timeout: 120000,
1696
+ validateStatus: () => true,
1697
+ });
1698
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
1699
+ return text(res.data && Object.keys(res.data).length ? res.data : { success: true, status: res.status });
1700
+ } catch (e) {
1701
+ handleError(e);
1702
+ }
1703
+ },
1704
+ });
1705
+ continue;
1706
+ }
1707
+
1708
+ if (isCsvGet) {
1709
+ toolsOut.push({
1710
+ name: ep.name,
1711
+ description: ep.description,
1712
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1713
+ handler: async (params) => {
1714
+ const queryPath = ep.path.includes('?') ? ep.path.split('?')[1] : '';
1715
+ const staticQuery = queryPath ? Object.fromEntries(new URLSearchParams(queryPath)) : {};
1716
+ const basePath = ep.path.split('?')[0];
1717
+ const { pathValues, rest } = splitPathAndRest(basePath, params);
1718
+ await ensureAccessToken();
1719
+ const url = getBaseUrl() + applyPathTemplate(basePath, pathValues);
1720
+ const headers = { ...buildAuthHeaders(), Accept: 'text/csv' };
1721
+ try {
1722
+ const res = await axios.get(url, {
1723
+ headers,
1724
+ params: { ...staticQuery, ...rest },
1725
+ responseType: 'text',
1726
+ timeout: 120000,
1727
+ validateStatus: () => true,
1728
+ });
1729
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
1730
+ return text({ format: 'text/csv', body: res.data ?? '' });
1731
+ } catch (e) {
1732
+ handleError(e);
1733
+ }
1734
+ },
1735
+ });
1736
+ continue;
1737
+ }
1738
+
1739
+ toolsOut.push({
1740
+ name: ep.name,
1741
+ description: ep.description,
1742
+ method: ep.method,
1743
+ path: ep.path,
1744
+ urlKind: 'versioned',
1745
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1746
+ handler: async (params) => {
1747
+ const [pbase, pq] = ep.path.split('?');
1748
+ const merged = { ...params };
1749
+ if (pq) {
1750
+ for (const [k, v] of new URLSearchParams(pq)) {
1751
+ if (merged[k] === undefined) merged[k] = v;
1752
+ }
1753
+ }
1754
+ const data = await sfRequest(ep.method, pbase, 'versioned', merged);
1755
+ return text(data);
1756
+ },
1757
+ });
1758
+ }
1759
+
1760
+ toolsOut.sort((a, b) => {
1761
+ const ea = endpoints.find((e) => e.name === a.name);
1762
+ const eb = endpoints.find((e) => e.name === b.name);
1763
+ const ra = methodRank(ea?.method);
1764
+ const rb = methodRank(eb?.method);
1765
+ if (ra !== rb) return ra - rb;
1766
+ return a.name.localeCompare(b.name);
1767
+ });
1768
+
1769
+ return toolsOut;
1770
+ }
1771
+
1772
+ const BULK_V1_PATH_MARK_JOB = '\uE000\uE001JOB\uE000\uE002';
1773
+ const BULK_V1_PATH_MARK_BATCH = '\uE000\uE001BATCH\uE000\uE002';
1774
+
1775
+ /** Preserve `{{_jobId}}` / `{{_batchId}}` as `{jobId}` / `{batchId}` after resolving `_endpoint` / `version`. */
1776
+ function bulkPathAwareResolve(raw, varMap) {
1777
+ let pre = String(raw);
1778
+ pre = pre.replace(/\{\{_jobId\}\}/g, BULK_V1_PATH_MARK_JOB);
1779
+ pre = pre.replace(/\{\{_batchId\}\}/g, BULK_V1_PATH_MARK_BATCH);
1780
+ let resolved = bulkResolve(pre, varMap);
1781
+ resolved = resolved.split(BULK_V1_PATH_MARK_JOB).join('{jobId}');
1782
+ resolved = resolved.split(BULK_V1_PATH_MARK_BATCH).join('{batchId}');
1783
+ return resolved;
1784
+ }
1785
+
1786
+ /** Paths under Bulk v1 split: REST bulk query jobs (`versioned`) or Async Bulk 1.0 (`bulk-v1-async`). */
1787
+ function bulkV1PathAndKind(req, varMap) {
1788
+ const raw = typeof req.url === 'string' ? req.url : req.url?.raw || '';
1789
+ const resolved = bulkPathAwareResolve(raw, varMap);
1790
+ const [pathPart, qs] = resolved.split('?');
1791
+ if (SKIP_BULK_PATH_RE.test(pathPart)) return null;
1792
+ const noHost = pathPart.replace(/^https?:\/\/[^/?#]+/i, '');
1793
+
1794
+ if (noHost.startsWith('/services/data/')) {
1795
+ const after = noHost.slice('/services/data/'.length);
1796
+ const m = after.match(/^(v[0-9.]+)\/(.+)$/);
1797
+ if (!m) return null;
1798
+ let path = `/${m[2]}`;
1799
+ path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
1800
+ if (qs) path = `${path}?${qs}`;
1801
+ return { path, urlKind: 'versioned' };
1802
+ }
1803
+
1804
+ if (noHost.startsWith('/services/async/')) {
1805
+ const after = noHost.slice('/services/async/'.length);
1806
+ const m = after.match(/^([0-9.]+)\/(.+)$/);
1807
+ if (!m) return null;
1808
+ let path = `/${m[2]}`;
1809
+ path = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
1810
+ if (qs) path = `${path}?${qs}`;
1811
+ return { path, urlKind: 'bulk-v1-async' };
1812
+ }
1813
+
1814
+ return null;
1815
+ }
1816
+
1817
+ function bulkV1BuildSchema(req, normalizedPath) {
1818
+ const base = bulkBuildSchema(req, normalizedPath);
1819
+ const pathNoQuery = normalizedPath.split('?')[0];
1820
+ const method = String(req.method || 'GET').toUpperCase();
1821
+ const isV1BatchPost = method === 'POST' && /\/job\/\{jobId\}\/batch$/.test(pathNoQuery);
1822
+ const isV1SpecPost = method === 'POST' && /\/job\/\{jobId\}\/spec$/.test(pathNoQuery);
1823
+ if (isV1BatchPost || isV1SpecPost) {
1824
+ base.properties.csv_data = {
1825
+ type: 'string',
1826
+ description: 'Request body as text/csv (UTF-8)',
1827
+ };
1828
+ base.required = [...new Set([...(base.required || []), 'csv_data'])];
1829
+ }
1830
+ return base;
1831
+ }
1832
+
1833
+ function bulkV1ExtractRequests(items, varMap, acc) {
1834
+ for (const item of items || []) {
1835
+ if (Array.isArray(item.item)) {
1836
+ bulkV1ExtractRequests(item.item, varMap, acc);
1837
+ } else if (item.request) {
1838
+ const req = item.request;
1839
+ const method = String(req.method || 'GET').toUpperCase();
1840
+ if (['OPTIONS', 'HEAD', 'TRACE'].includes(method)) continue;
1841
+
1842
+ const parsed = bulkV1PathAndKind(req, varMap);
1843
+ if (!parsed) continue;
1844
+
1845
+ const { path, urlKind } = parsed;
1846
+ const pathKey = path.split('?')[0];
1847
+ const dedupeKey = `${method}|${pathKey}|${urlKind}`;
1848
+
1849
+ acc.push({
1850
+ dedupeKey,
1851
+ name: `bulk_v1_${bulkSlug(item.name, MCP_TOOL_NAME_MAX_LEN - 'bulk_v1_'.length)}`,
1852
+ description: bulkDesc(req, item.name),
1853
+ method,
1854
+ path,
1855
+ urlKind,
1856
+ inputSchema: bulkV1BuildSchema(req, path.split('?')[0]),
1857
+ });
1858
+ }
1859
+ }
1860
+ }
1861
+
1862
+ function bulkV1ManifestEndpoints() {
1863
+ if (!existsSync(BULK_V1_POSTMAN)) return [];
1864
+ const col = JSON.parse(readFileSync(BULK_V1_POSTMAN, 'utf8'));
1865
+ const varMap = bulkVarMap(col);
1866
+ const raw = [];
1867
+ bulkV1ExtractRequests(col.item, varMap, raw);
1868
+
1869
+ const seen = new Set();
1870
+ const endpoints = [];
1871
+ for (const ep of raw) {
1872
+ if (seen.has(ep.dedupeKey)) continue;
1873
+ seen.add(ep.dedupeKey);
1874
+ const { dedupeKey: _d, ...rest } = ep;
1875
+ endpoints.push(rest);
1876
+ }
1877
+ return endpoints;
1878
+ }
1879
+
1880
+ function bulkV1IsCsvQueryResultGet(method, pathNoQuery) {
1881
+ return method === 'GET' && /\/jobs\/query\/\{jobId\}\/results$/.test(pathNoQuery);
1882
+ }
1883
+
1884
+ function bulkV1IsCsvAsyncResultGet(method, pathNoQuery) {
1885
+ return (
1886
+ method === 'GET' &&
1887
+ /\/job\/\{jobId\}\/batch\/\{batchId\}\/result(\/\{batchResultId\})?$/.test(pathNoQuery)
1888
+ );
1889
+ }
1890
+
1891
+ function bulkV1IsCsvPost(method, pathNoQuery) {
1892
+ return (
1893
+ method === 'POST' &&
1894
+ (/\/job\/\{jobId\}\/batch$/.test(pathNoQuery) || /\/job\/\{jobId\}\/spec$/.test(pathNoQuery))
1895
+ );
1896
+ }
1897
+
1898
+ function bulkV1GeneratedTools() {
1899
+ const endpoints = bulkV1ManifestEndpoints();
1900
+ const toolsOut = [];
1901
+
1902
+ for (const ep of endpoints) {
1903
+ const pathNoQuery = ep.path.split('?')[0];
1904
+ const isCsvPost = bulkV1IsCsvPost(ep.method, pathNoQuery);
1905
+ const isCsvGetQuery = bulkV1IsCsvQueryResultGet(ep.method, pathNoQuery);
1906
+ const isCsvGetAsync = bulkV1IsCsvAsyncResultGet(ep.method, pathNoQuery);
1907
+
1908
+ if (isCsvPost) {
1909
+ toolsOut.push({
1910
+ name: ep.name,
1911
+ description: ep.description,
1912
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1913
+ handler: async (params) => {
1914
+ const [pbase, pq] = ep.path.split('?');
1915
+ const { pathValues, rest } = splitPathAndRest(pbase, params);
1916
+ const csv = rest.csv_data;
1917
+ if (csv == null) throw new Error('csv_data is required');
1918
+ await ensureAccessToken();
1919
+ const url = getBulkV1AsyncBaseUrl() + applyPathTemplate(pbase, pathValues);
1920
+ const staticQ = pq ? Object.fromEntries(new URLSearchParams(pq)) : {};
1921
+ const restQuery = { ...rest };
1922
+ delete restQuery.csv_data;
1923
+ delete restQuery._rawBody;
1924
+ const headers = {
1925
+ ...buildAuthHeaders(),
1926
+ 'Content-Type': 'text/csv',
1927
+ Accept: 'application/json',
1928
+ };
1929
+ try {
1930
+ const res = await axios.post(url, csv, {
1931
+ headers,
1932
+ params: { ...staticQ, ...restQuery },
1933
+ timeout: 120000,
1934
+ validateStatus: () => true,
1935
+ });
1936
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
1937
+ const d = res.data;
1938
+ if (d == null || (typeof d === 'object' && !Object.keys(d).length)) {
1939
+ return text({ success: true, status: res.status });
1940
+ }
1941
+ return text(
1942
+ typeof d === 'string' ? { format: 'text', body: d } : d
1943
+ );
1944
+ } catch (e) {
1945
+ handleError(e);
1946
+ }
1947
+ },
1948
+ });
1949
+ continue;
1950
+ }
1951
+
1952
+ if (isCsvGetQuery) {
1953
+ toolsOut.push({
1954
+ name: ep.name,
1955
+ description: ep.description,
1956
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1957
+ handler: async (params) => {
1958
+ const queryPath = ep.path.includes('?') ? ep.path.split('?')[1] : '';
1959
+ const staticQuery = queryPath ? Object.fromEntries(new URLSearchParams(queryPath)) : {};
1960
+ const basePath = ep.path.split('?')[0];
1961
+ const { pathValues, rest } = splitPathAndRest(basePath, params);
1962
+ await ensureAccessToken();
1963
+ const url = getBaseUrl() + applyPathTemplate(basePath, pathValues);
1964
+ const headers = { ...buildAuthHeaders(), Accept: 'text/csv' };
1965
+ const restQ = { ...rest };
1966
+ delete restQ._rawBody;
1967
+ try {
1968
+ const res = await axios.get(url, {
1969
+ headers,
1970
+ params: { ...staticQuery, ...restQ },
1971
+ responseType: 'text',
1972
+ timeout: 120000,
1973
+ validateStatus: () => true,
1974
+ });
1975
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
1976
+ return text({ format: 'text/csv', body: res.data ?? '' });
1977
+ } catch (e) {
1978
+ handleError(e);
1979
+ }
1980
+ },
1981
+ });
1982
+ continue;
1983
+ }
1984
+
1985
+ if (isCsvGetAsync) {
1986
+ toolsOut.push({
1987
+ name: ep.name,
1988
+ description: ep.description,
1989
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
1990
+ handler: async (params) => {
1991
+ const basePath = ep.path.split('?')[0];
1992
+ const { pathValues, rest } = splitPathAndRest(basePath, params);
1993
+ await ensureAccessToken();
1994
+ const url = getBulkV1AsyncBaseUrl() + applyPathTemplate(basePath, pathValues);
1995
+ const headers = { ...buildAuthHeaders(), Accept: 'text/csv' };
1996
+ const restQ = { ...rest };
1997
+ delete restQ._rawBody;
1998
+ try {
1999
+ const res = await axios.get(url, {
2000
+ headers,
2001
+ params: restQ,
2002
+ responseType: 'text',
2003
+ timeout: 120000,
2004
+ validateStatus: () => true,
2005
+ });
2006
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2007
+ return text({ format: 'text/csv', body: res.data ?? '' });
2008
+ } catch (e) {
2009
+ handleError(e);
2010
+ }
2011
+ },
2012
+ });
2013
+ continue;
2014
+ }
2015
+
2016
+ toolsOut.push({
2017
+ name: ep.name,
2018
+ description: ep.description,
2019
+ method: ep.method,
2020
+ path: ep.path,
2021
+ urlKind: ep.urlKind,
2022
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
2023
+ handler: async (params) => {
2024
+ const [pbase, pq] = ep.path.split('?');
2025
+ const merged = { ...params };
2026
+ if (pq) {
2027
+ for (const [k, v] of new URLSearchParams(pq)) {
2028
+ if (merged[k] === undefined) merged[k] = v;
2029
+ }
2030
+ }
2031
+ const data = await sfRequest(ep.method, pbase, ep.urlKind, merged);
2032
+ return text(data);
2033
+ },
2034
+ });
2035
+ }
2036
+
2037
+ toolsOut.sort((a, b) => {
2038
+ const ea = endpoints.find((e) => e.name === a.name);
2039
+ const eb = endpoints.find((e) => e.name === b.name);
2040
+ const ra = methodRank(ea?.method);
2041
+ const rb = methodRank(eb?.method);
2042
+ if (ra !== rb) return ra - rb;
2043
+ return a.name.localeCompare(b.name);
2044
+ });
2045
+
2046
+ return toolsOut;
2047
+ }
2048
+
2049
+ /** Pathname e.g. /services/data/v59.0 for Composite sub-request URLs. */
2050
+ function compositeDataPath() {
2051
+ const base = getBaseUrl();
2052
+ try {
2053
+ const pathname = new URL(base).pathname.replace(/\/$/, '');
2054
+ if (!pathname.includes('/services/data/')) {
2055
+ throw new Error('expected /services/data/ in base URL');
2056
+ }
2057
+ return pathname;
2058
+ } catch (e) {
2059
+ throw new Error(`Composite API: invalid instance base URL (${e.message})`);
2060
+ }
2061
+ }
2062
+
2063
+ function compositeBatchUrlSegment(urlIn) {
2064
+ const u = String(urlIn || '').trim();
2065
+ if (!u) throw new Error('Each batch request needs a non-empty url');
2066
+ if (/^v[0-9.]+\//i.test(u)) return u;
2067
+ if (u.startsWith('/services/data/')) {
2068
+ const m = u.match(/\/services\/data\/(v[0-9.]+)\/(.+)$/i);
2069
+ if (m) return `${m[1]}/${m[2]}`;
2070
+ }
2071
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
2072
+ const v = verRaw.startsWith('v') ? verRaw : `v${verRaw}`;
2073
+ return `${v}/${u.replace(/^\//, '')}`;
2074
+ }
2075
+
2076
+ function compositeSmartTools() {
2077
+ return [
2078
+ {
2079
+ name: 'composite_create_acct_with_contact',
2080
+ description:
2081
+ 'Creates an Account and a linked Contact in a single atomic API call using Salesforce Composite API, ensuring both records are created together or neither is created if any step fails. Use this instead of separate create_account and create_contact calls when onboarding new customers to maintain data integrity and reduce API call consumption.',
2082
+ inputSchema: {
2083
+ type: 'object',
2084
+ properties: {
2085
+ account: {
2086
+ type: 'object',
2087
+ description: 'Account fields (e.g. Name, Industry, BillingCity)',
2088
+ additionalProperties: true,
2089
+ },
2090
+ contact: {
2091
+ type: 'object',
2092
+ description:
2093
+ 'Contact fields (e.g. LastName, FirstName, Email); AccountId is set automatically from the new account',
2094
+ additionalProperties: true,
2095
+ },
2096
+ },
2097
+ required: ['account', 'contact'],
2098
+ },
2099
+ handler: async ({ account, contact }) => {
2100
+ const root = compositeDataPath();
2101
+ const payload = {
2102
+ compositeRequest: [
2103
+ {
2104
+ method: 'POST',
2105
+ url: `${root}/sobjects/Account`,
2106
+ referenceId: 'refAccount',
2107
+ body: { ...account },
2108
+ },
2109
+ {
2110
+ method: 'POST',
2111
+ url: `${root}/sobjects/Contact`,
2112
+ referenceId: 'refContact',
2113
+ body: { ...contact, AccountId: '@{refAccount.id}' },
2114
+ },
2115
+ ],
2116
+ };
2117
+ await ensureAccessToken();
2118
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json', Accept: 'application/json' };
2119
+ try {
2120
+ const res = await axios.post(`${getBaseUrl()}/composite/`, payload, {
2121
+ headers,
2122
+ timeout: 120000,
2123
+ validateStatus: () => true,
2124
+ });
2125
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2126
+ return text(res.data);
2127
+ } catch (e) {
2128
+ handleError(e);
2129
+ }
2130
+ },
2131
+ },
2132
+ {
2133
+ name: 'comp_opp_with_contact_role',
2134
+ description:
2135
+ 'Creates an Opportunity record and simultaneously assigns a Contact Role to link a contact to that opportunity in one atomic operation. Use this when you know the primary contact for a new deal upfront, rather than creating the opportunity first and then adding contact roles separately.',
2136
+ inputSchema: {
2137
+ type: 'object',
2138
+ properties: {
2139
+ opportunity: {
2140
+ type: 'object',
2141
+ description: 'Opportunity fields (e.g. Name, StageName, CloseDate, AccountId, Amount)',
2142
+ additionalProperties: true,
2143
+ },
2144
+ contact_id: { type: 'string', description: 'Contact Id for the opportunity contact role' },
2145
+ role: {
2146
+ type: 'string',
2147
+ description: 'Role name (e.g. Decision Maker). Default: Primary Contact',
2148
+ },
2149
+ is_primary: { type: 'boolean', description: 'Optional IsPrimary on OpportunityContactRole' },
2150
+ },
2151
+ required: ['opportunity', 'contact_id'],
2152
+ },
2153
+ handler: async ({ opportunity, contact_id, role, is_primary }) => {
2154
+ const root = compositeDataPath();
2155
+ const ocr = {
2156
+ OpportunityId: '@{refOpp.id}',
2157
+ ContactId: contact_id,
2158
+ Role: role || 'Primary Contact',
2159
+ };
2160
+ if (is_primary === true) ocr.IsPrimary = true;
2161
+
2162
+ const payload = {
2163
+ compositeRequest: [
2164
+ {
2165
+ method: 'POST',
2166
+ url: `${root}/sobjects/Opportunity`,
2167
+ referenceId: 'refOpp',
2168
+ body: { ...opportunity },
2169
+ },
2170
+ {
2171
+ method: 'POST',
2172
+ url: `${root}/sobjects/OpportunityContactRole`,
2173
+ referenceId: 'refOcr',
2174
+ body: ocr,
2175
+ },
2176
+ ],
2177
+ };
2178
+ await ensureAccessToken();
2179
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json', Accept: 'application/json' };
2180
+ try {
2181
+ const res = await axios.post(`${getBaseUrl()}/composite/`, payload, {
2182
+ headers,
2183
+ timeout: 120000,
2184
+ validateStatus: () => true,
2185
+ });
2186
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2187
+ return text(res.data);
2188
+ } catch (e) {
2189
+ handleError(e);
2190
+ }
2191
+ },
2192
+ },
2193
+ {
2194
+ name: 'batch_get_records',
2195
+ description:
2196
+ 'Retrieves multiple records of different Salesforce object types (up to 25) in a single API call using Composite Batch, returning all requested records in one response. Use this instead of multiple individual GET requests when you need records from different objects to significantly reduce API call usage.',
2197
+ inputSchema: {
2198
+ type: 'object',
2199
+ properties: {
2200
+ requests: {
2201
+ type: 'array',
2202
+ description:
2203
+ 'Each entry: { method (default GET), url } — url is the path after /services/data/, e.g. v59.0/sobjects/Account/001xxx or v59.0/query/?q=SELECT+Id+FROM+Contact+LIMIT+1',
2204
+ items: {
2205
+ type: 'object',
2206
+ properties: {
2207
+ method: { type: 'string' },
2208
+ url: { type: 'string' },
2209
+ },
2210
+ required: ['url'],
2211
+ },
2212
+ },
2213
+ halt_on_error: {
2214
+ type: 'boolean',
2215
+ description: 'Maps to haltOnError (default true)',
2216
+ },
2217
+ },
2218
+ required: ['requests'],
2219
+ },
2220
+ handler: async ({ requests, halt_on_error }) => {
2221
+ if (!Array.isArray(requests) || !requests.length) {
2222
+ throw new Error('requests must be a non-empty array');
2223
+ }
2224
+ const batchRequests = requests.map((r) => ({
2225
+ method: (r.method || 'GET').toUpperCase(),
2226
+ url: compositeBatchUrlSegment(r.url),
2227
+ }));
2228
+ const payload = {
2229
+ haltOnError: halt_on_error !== false,
2230
+ batchRequests,
2231
+ };
2232
+ await ensureAccessToken();
2233
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json', Accept: 'application/json' };
2234
+ try {
2235
+ const res = await axios.post(`${getBaseUrl()}/composite/batch`, payload, {
2236
+ headers,
2237
+ timeout: 120000,
2238
+ validateStatus: () => true,
2239
+ });
2240
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2241
+ return text(res.data);
2242
+ } catch (e) {
2243
+ handleError(e);
2244
+ }
2245
+ },
2246
+ },
2247
+ {
2248
+ name: 'mass_transfer_ownership',
2249
+ description:
2250
+ 'Transfers ownership of multiple Salesforce records to a new user in a single composite operation, processing up to 200 records at once. Use this instead of individual record updates when reassigning territories, handling user departures, or bulk ownership changes to minimize API calls and processing time.',
2251
+ inputSchema: {
2252
+ type: 'object',
2253
+ properties: {
2254
+ object_type: { type: 'string', description: 'SObject API name (same type for all ids)' },
2255
+ record_ids: { type: 'array', items: { type: 'string' } },
2256
+ new_owner_id: { type: 'string', description: 'User Id (005...) to assign as OwnerId' },
2257
+ all_or_none: { type: 'boolean', description: 'Default false' },
2258
+ },
2259
+ required: ['object_type', 'record_ids', 'new_owner_id'],
2260
+ },
2261
+ handler: async ({ object_type, record_ids, new_owner_id, all_or_none }) => {
2262
+ if (!record_ids?.length) throw new Error('record_ids must be non-empty');
2263
+ const records = record_ids.map((id) => ({
2264
+ attributes: { type: object_type },
2265
+ id: String(id).trim(),
2266
+ OwnerId: new_owner_id,
2267
+ }));
2268
+ const payload = { allOrNone: all_or_none === true, records };
2269
+ await ensureAccessToken();
2270
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json', Accept: 'application/json' };
2271
+ try {
2272
+ const res = await axios.patch(`${getBaseUrl()}/composite/sobjects`, payload, {
2273
+ headers,
2274
+ timeout: 120000,
2275
+ validateStatus: () => true,
2276
+ });
2277
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2278
+ return text(res.data);
2279
+ } catch (e) {
2280
+ handleError(e);
2281
+ }
2282
+ },
2283
+ },
2284
+ ];
2285
+ }
2286
+
2287
+ function getSalesforceApiVersionNumber() {
2288
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
2289
+ return String(verRaw).replace(/^v/i, '');
2290
+ }
2291
+
2292
+ function getSoapMetadataEndpointUrl() {
2293
+ const inst =
2294
+ process.env.SALESFORCE_INSTANCE_URL?.replace(/\/$/, '') || getInstanceUrl()?.replace(/\/$/, '');
2295
+ if (!inst) {
2296
+ throw new Error('SALESFORCE_INSTANCE_URL is not set.');
2297
+ }
2298
+ return `${inst}/services/Soap/m/${getSalesforceApiVersionNumber()}`;
2299
+ }
2300
+
2301
+ function escapeXmlText(s) {
2302
+ return String(s)
2303
+ .replace(/&/g, '&amp;')
2304
+ .replace(/</g, '&lt;')
2305
+ .replace(/>/g, '&gt;')
2306
+ .replace(/"/g, '&quot;');
2307
+ }
2308
+
2309
+ function metadataWalkSoapRequests(items, out) {
2310
+ for (const item of items || []) {
2311
+ if (Array.isArray(item.item)) {
2312
+ metadataWalkSoapRequests(item.item, out);
2313
+ } else if (item.request) {
2314
+ const r = item.request;
2315
+ const hdrs = {};
2316
+ for (const h of r.header || []) {
2317
+ if (h.disabled || !h.key) continue;
2318
+ hdrs[h.key] = h.value || '';
2319
+ }
2320
+ out.push({
2321
+ itemName: item.name,
2322
+ body: r.body?.mode === 'raw' ? r.body.raw || '' : '',
2323
+ headers: hdrs,
2324
+ });
2325
+ }
2326
+ }
2327
+ }
2328
+
2329
+ function loadMetadataPostmanSoapRequests() {
2330
+ if (!existsSync(METADATA_POSTMAN)) return [];
2331
+ const col = JSON.parse(readFileSync(METADATA_POSTMAN, 'utf8'));
2332
+ const out = [];
2333
+ metadataWalkSoapRequests(col.item, out);
2334
+ return out;
2335
+ }
2336
+
2337
+ async function postMetadataSoapXml(xmlBody, headerTemplate) {
2338
+ await ensureAccessToken();
2339
+ const token = getToken();
2340
+ if (!token) {
2341
+ throw new Error('Not authenticated. Run: npx adoptai-salesforce-mcp --client cursor');
2342
+ }
2343
+ const xml = xmlBody.replace(/\{\{_accessToken\}\}/g, token);
2344
+ const url = getSoapMetadataEndpointUrl();
2345
+ const headers = {
2346
+ 'Content-Type': 'text/xml; charset=UTF-8',
2347
+ Accept: 'text/xml',
2348
+ ...headerTemplate,
2349
+ };
2350
+ try {
2351
+ const res = await axios.post(url, xml, {
2352
+ headers,
2353
+ timeout: 120000,
2354
+ validateStatus: () => true,
2355
+ });
2356
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2357
+ const payload = typeof res.data === 'string' ? { format: 'text/xml', body: res.data } : res.data;
2358
+ return text(payload);
2359
+ } catch (e) {
2360
+ handleError(e);
2361
+ }
2362
+ }
2363
+
2364
+ function metadataSoapSlug(itemName) {
2365
+ const base = itemName
2366
+ .replace(/^SOAP\s+/i, '')
2367
+ .replace(/[^a-zA-Z0-9_]+/g, '_')
2368
+ .replace(/^_+|_+$/g, '')
2369
+ .toLowerCase();
2370
+ return `metadata_soap_${bulkSlug(base, MCP_TOOL_NAME_MAX_LEN - 'metadata_soap_'.length)}`;
2371
+ }
2372
+
2373
+ /** Section B: all Metadata API requests in the Postman split (SOAP to /services/Soap/m/). */
2374
+ function metadataSoapToolsFromPostman() {
2375
+ const reqs = loadMetadataPostmanSoapRequests();
2376
+ const tools = [];
2377
+ for (const req of reqs) {
2378
+ const slug = metadataSoapSlug(req.itemName);
2379
+ if (/describe\s*metadata/i.test(req.itemName)) {
2380
+ tools.push({
2381
+ name: slug,
2382
+ description:
2383
+ 'Salesforce Metadata SOAP describeMetadata — metadata component types available for this org. From Postman collection salesforce-metadata.',
2384
+ inputSchema: {
2385
+ type: 'object',
2386
+ properties: {
2387
+ as_of_version: {
2388
+ type: 'string',
2389
+ description: 'asOfVersion e.g. 59.0 (defaults to SALESFORCE_API_VERSION without leading v)',
2390
+ },
2391
+ },
2392
+ },
2393
+ handler: async ({ as_of_version }) => {
2394
+ const ver = as_of_version || getSalesforceApiVersionNumber();
2395
+ let xml = req.body.replace(/\{\{version\}\}/g, ver);
2396
+ return postMetadataSoapXml(xml, req.headers);
2397
+ },
2398
+ });
2399
+ } else if (/describe\s*value\s*type/i.test(req.itemName)) {
2400
+ tools.push({
2401
+ name: slug,
2402
+ description:
2403
+ 'Salesforce Metadata SOAP describeValueType — details for a metadata type QName. From Postman collection salesforce-metadata.',
2404
+ inputSchema: {
2405
+ type: 'object',
2406
+ properties: {
2407
+ metadata_type: {
2408
+ type: 'string',
2409
+ description: 'Type name e.g. CustomObject, Layout, ApexClass',
2410
+ },
2411
+ },
2412
+ required: ['metadata_type'],
2413
+ },
2414
+ handler: async ({ metadata_type }) => {
2415
+ let xml = req.body.replace(/\{\{version\}\}/g, getSalesforceApiVersionNumber());
2416
+ xml = xml.replace(/INSERT_METADATA_TYPE_NAME/g, metadata_type);
2417
+ return postMetadataSoapXml(xml, req.headers);
2418
+ },
2419
+ });
2420
+ } else if (/list\s*metadata/i.test(req.itemName)) {
2421
+ tools.push({
2422
+ name: slug,
2423
+ description:
2424
+ 'Salesforce Metadata SOAP listMetadata — list components of a metadata type. From Postman collection salesforce-metadata.',
2425
+ inputSchema: {
2426
+ type: 'object',
2427
+ properties: {
2428
+ metadata_type: {
2429
+ type: 'string',
2430
+ description: 'e.g. CustomObject, ApexClass, Dashboard',
2431
+ },
2432
+ folder: { type: 'string', description: 'Folder name when the type uses folders' },
2433
+ as_of_version: { type: 'string' },
2434
+ },
2435
+ required: ['metadata_type'],
2436
+ },
2437
+ handler: async ({ metadata_type, folder, as_of_version }) => {
2438
+ const ver = as_of_version || getSalesforceApiVersionNumber();
2439
+ let xml = req.body.replace(/\{\{version\}\}/g, ver);
2440
+ xml = xml.replace(/<type>[^<]*<\/type>/, `<type>${escapeXmlText(metadata_type)}</type>`);
2441
+ const folderXml = folder != null && folder !== '' ? escapeXmlText(folder) : '';
2442
+ xml = xml.replace(/<folder>[^<]*<\/folder>/, `<folder>${folderXml}</folder>`);
2443
+ return postMetadataSoapXml(xml, req.headers);
2444
+ },
2445
+ });
2446
+ }
2447
+ }
2448
+ return tools;
2449
+ }
2450
+
2451
+ function findDescribeField(describe, fieldApiName) {
2452
+ const want = String(fieldApiName || '').toLowerCase();
2453
+ const fields = describe?.fields || [];
2454
+ return fields.find((f) => (f.name || '').toLowerCase() === want);
2455
+ }
2456
+
2457
+ function extractPicklistEntries(field) {
2458
+ if (!field) return null;
2459
+ const out = [];
2460
+ if (Array.isArray(field.picklistValues)) {
2461
+ for (const p of field.picklistValues) {
2462
+ if (p.active !== false) {
2463
+ out.push({ label: p.label, value: p.value, defaultValue: p.defaultValue });
2464
+ }
2465
+ }
2466
+ return out.length ? out : null;
2467
+ }
2468
+ const vs = field.valueSet;
2469
+ if (vs?.valueSetDefinition?.value && Array.isArray(vs.valueSetDefinition.value)) {
2470
+ for (const v of vs.valueSetDefinition.value) {
2471
+ out.push({ label: v.label, value: v.value, default: v.default });
2472
+ }
2473
+ return out.length ? out : null;
2474
+ }
2475
+ if (vs?.controllingField) {
2476
+ return {
2477
+ message: 'Dependent picklist — use describe_sobject and controlling field context',
2478
+ controllingField: vs.controllingField,
2479
+ };
2480
+ }
2481
+ return null;
2482
+ }
2483
+
2484
+ function metadataRestSmartTools() {
2485
+ return [
2486
+ {
2487
+ name: 'list_sobjects',
2488
+ description:
2489
+ 'Returns a comprehensive list of all available Salesforce objects in the org including standard objects (Account, Contact, etc.) and custom objects with their API names and metadata URLs. Use this discovery tool before querying or describing objects to understand what data structures are available in the org.',
2490
+ method: 'GET',
2491
+ path: '/sobjects/',
2492
+ urlKind: 'versioned',
2493
+ inputSchema: { type: 'object', properties: {} },
2494
+ handler: async () => {
2495
+ await ensureAccessToken();
2496
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2497
+ try {
2498
+ const res = await axios.get(`${getBaseUrl()}/sobjects/`, {
2499
+ headers,
2500
+ timeout: 120000,
2501
+ validateStatus: () => true,
2502
+ });
2503
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2504
+ return text(res.data);
2505
+ } catch (e) {
2506
+ handleError(e);
2507
+ }
2508
+ },
2509
+ },
2510
+ {
2511
+ name: 'describe_sobject',
2512
+ description:
2513
+ 'Returns complete metadata schema for a specific Salesforce object including all field names, data types, field properties, picklist values, and relationship information. Use this before building SOQL queries or creating records to understand the object structure and available fields.',
2514
+ method: 'GET',
2515
+ path: '/sobjects/{object_type}/describe',
2516
+ urlKind: 'versioned',
2517
+ inputSchema: {
2518
+ type: 'object',
2519
+ properties: {
2520
+ object_type: {
2521
+ type: 'string',
2522
+ description: 'SObject API name e.g. Account, Custom__c',
2523
+ },
2524
+ },
2525
+ required: ['object_type'],
2526
+ },
2527
+ handler: async ({ object_type }) => {
2528
+ await ensureAccessToken();
2529
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2530
+ try {
2531
+ const res = await axios.get(
2532
+ `${getBaseUrl()}/sobjects/${encodeURIComponent(object_type)}/describe`,
2533
+ { headers, timeout: 120000, validateStatus: () => true }
2534
+ );
2535
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2536
+ return text(res.data);
2537
+ } catch (e) {
2538
+ handleError(e);
2539
+ }
2540
+ },
2541
+ },
2542
+ {
2543
+ name: 'get_picklist_values',
2544
+ description:
2545
+ 'Returns all valid picklist values and their labels for a specific picklist field on any Salesforce object, including dependent picklist logic if applicable. Use this before creating or updating records to ensure you\'re using valid values for fields like Opportunity Stage, Lead Status, or Account Industry.',
2546
+ inputSchema: {
2547
+ type: 'object',
2548
+ properties: {
2549
+ object_type: { type: 'string' },
2550
+ field_name: { type: 'string', description: 'Field API name e.g. StageName, Industry' },
2551
+ },
2552
+ required: ['object_type', 'field_name'],
2553
+ },
2554
+ handler: async ({ object_type, field_name }) => {
2555
+ await ensureAccessToken();
2556
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2557
+ let describe;
2558
+ try {
2559
+ const res = await axios.get(
2560
+ `${getBaseUrl()}/sobjects/${encodeURIComponent(object_type)}/describe`,
2561
+ { headers, timeout: 120000, validateStatus: () => true }
2562
+ );
2563
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2564
+ describe = res.data;
2565
+ } catch (e) {
2566
+ handleError(e);
2567
+ }
2568
+ const field = findDescribeField(describe, field_name);
2569
+ if (!field) {
2570
+ return text({ error: 'Field not found', object_type, field_name });
2571
+ }
2572
+ const values = extractPicklistEntries(field);
2573
+ return text({
2574
+ object_type,
2575
+ field_name: field.name,
2576
+ label: field.label,
2577
+ type: field.type,
2578
+ values,
2579
+ });
2580
+ },
2581
+ },
2582
+ {
2583
+ name: 'get_org_limits',
2584
+ description:
2585
+ 'Returns current API usage statistics and limits for the Salesforce org including daily API calls used/remaining, data storage limits, and other consumption metrics. Use this to monitor integration health and avoid hitting API limits that could disrupt operations.',
2586
+ method: 'GET',
2587
+ path: '/limits',
2588
+ urlKind: 'versioned',
2589
+ inputSchema: { type: 'object', properties: {} },
2590
+ handler: async () => {
2591
+ await ensureAccessToken();
2592
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2593
+ try {
2594
+ const res = await axios.get(`${getBaseUrl()}/limits`, {
2595
+ headers,
2596
+ timeout: 60000,
2597
+ validateStatus: () => true,
2598
+ });
2599
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2600
+ return text(res.data);
2601
+ } catch (e) {
2602
+ handleError(e);
2603
+ }
2604
+ },
2605
+ },
2606
+ {
2607
+ name: 'get_record_types',
2608
+ description:
2609
+ 'Returns all available record types for a specific Salesforce object including their names, IDs, and default field values. Use this before creating records in orgs that implement record types to ensure you\'re specifying the correct record type ID.',
2610
+ inputSchema: {
2611
+ type: 'object',
2612
+ properties: {
2613
+ object_type: { type: 'string' },
2614
+ },
2615
+ required: ['object_type'],
2616
+ },
2617
+ handler: async ({ object_type }) => {
2618
+ await ensureAccessToken();
2619
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2620
+ let describe;
2621
+ try {
2622
+ const res = await axios.get(
2623
+ `${getBaseUrl()}/sobjects/${encodeURIComponent(object_type)}/describe`,
2624
+ { headers, timeout: 120000, validateStatus: () => true }
2625
+ );
2626
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2627
+ describe = res.data;
2628
+ } catch (e) {
2629
+ handleError(e);
2630
+ }
2631
+ const infos = describe?.recordTypeInfos || [];
2632
+ return text({
2633
+ object_type,
2634
+ recordTypeInfos: infos,
2635
+ count: infos.length,
2636
+ });
2637
+ },
2638
+ },
2639
+ ];
2640
+ }
2641
+
2642
+ function multipliedTools() {
2643
+ return [
2644
+ {
2645
+ name: 'get_account',
2646
+ description:
2647
+ 'Retrieves a single Account record by ID returning standard fields like Name, Industry, Type, Phone, Website, and BillingAddress. Use this for basic account information only; for accounts with related data use get_account_with_contacts or get_account_with_opportunities instead.',
2648
+ inputSchema: {
2649
+ type: 'object',
2650
+ properties: {
2651
+ account_id: { type: 'string', description: 'Salesforce Account Id' },
2652
+ fields: {
2653
+ type: 'string',
2654
+ description: 'Optional comma-separated API field names (e.g. Name,Industry,BillingCity)',
2655
+ },
2656
+ },
2657
+ required: ['account_id'],
2658
+ },
2659
+ handler: async ({ account_id, fields }) => {
2660
+ const q = fields
2661
+ ? `?fields=${encodeURIComponent(fields)}`
2662
+ : '';
2663
+ await ensureAccessToken();
2664
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
2665
+ try {
2666
+ const res = await axios.get(`${getBaseUrl()}/sobjects/Account/${encodeURIComponent(account_id)}${q}`, {
2667
+ headers,
2668
+ timeout: 60000,
2669
+ });
2670
+ return text(res.data);
2671
+ } catch (e) {
2672
+ handleError(e);
2673
+ }
2674
+ },
2675
+ },
2676
+ {
2677
+ name: 'get_account_with_contacts',
2678
+ description:
2679
+ 'Retrieves an account record along with all its related contacts in a single SOQL query, returning account details plus contact information like names, emails, and phone numbers. Use this instead of separate get_account and get_contacts calls when you need both datasets together for relationship mapping or communication planning.',
2680
+ inputSchema: {
2681
+ type: 'object',
2682
+ properties: {
2683
+ account_id: { type: 'string', description: 'Salesforce Account Id' },
2684
+ },
2685
+ required: ['account_id'],
2686
+ },
2687
+ handler: async ({ account_id }) => {
2688
+ const id = escapeSoqlString(account_id);
2689
+ const q = `SELECT Id,Name,Industry,Type,Phone,Website,BillingStreet,BillingCity,BillingState,BillingPostalCode,BillingCountry,(SELECT Id,FirstName,LastName,Email,Phone FROM Contacts) FROM Account WHERE Id='${id}'`;
2690
+ return text(await soqlGet(q));
2691
+ },
2692
+ },
2693
+ {
2694
+ name: 'get_account_with_opportunities',
2695
+ description:
2696
+ 'Fetches an account and all its associated opportunities with key sales fields (stage, amount, close date) in one query. Use this for sales pipeline reviews or account revenue analysis rather than making separate account and opportunity API calls.',
2697
+ inputSchema: {
2698
+ type: 'object',
2699
+ properties: {
2700
+ account_id: { type: 'string' },
2701
+ },
2702
+ required: ['account_id'],
2703
+ },
2704
+ handler: async ({ account_id }) => {
2705
+ const id = escapeSoqlString(account_id);
2706
+ const q = `SELECT Id,Name,Industry,(SELECT Id,Name,StageName,Amount,CloseDate,Probability FROM Opportunities) FROM Account WHERE Id='${id}'`;
2707
+ return text(await soqlGet(q));
2708
+ },
2709
+ },
2710
+ {
2711
+ name: 'get_account_with_cases',
2712
+ description:
2713
+ 'Retrieves an account along with all its open support cases, showing case numbers, subjects, priorities, and status. Use this for customer health assessments or before support escalation calls instead of querying accounts and cases separately.',
2714
+ inputSchema: {
2715
+ type: 'object',
2716
+ properties: {
2717
+ account_id: { type: 'string' },
2718
+ },
2719
+ required: ['account_id'],
2720
+ },
2721
+ handler: async ({ account_id }) => {
2722
+ const id = escapeSoqlString(account_id);
2723
+ const q = `SELECT Id,Name,(SELECT Id,CaseNumber,Subject,Status,Priority,CreatedDate FROM Cases WHERE IsClosed=false) FROM Account WHERE Id='${id}'`;
2724
+ return text(await soqlGet(q));
2725
+ },
2726
+ },
2727
+ {
2728
+ name: 'get_account_full_profile',
2729
+ description:
2730
+ 'Fetches a comprehensive account view including all related contacts, opportunities, and cases in a single API call. Use this for complete customer profiles before meetings or account reviews, but avoid for focused workflows where you only need specific related data to minimize response size.',
2731
+ inputSchema: {
2732
+ type: 'object',
2733
+ properties: {
2734
+ account_id: { type: 'string' },
2735
+ },
2736
+ required: ['account_id'],
2737
+ },
2738
+ handler: async ({ account_id }) => {
2739
+ const id = escapeSoqlString(account_id);
2740
+ const q = `SELECT Id,Name,Industry,Type,Phone,Website,BillingCity,BillingCountry,(SELECT Id,FirstName,LastName,Email,Phone FROM Contacts),(SELECT Id,Name,StageName,Amount,CloseDate FROM Opportunities),(SELECT Id,CaseNumber,Subject,Status FROM Cases WHERE IsClosed=false) FROM Account WHERE Id='${id}'`;
2741
+ return text(await soqlGet(q));
2742
+ },
2743
+ },
2744
+ {
2745
+ name: 'update_opportunity',
2746
+ description:
2747
+ 'Updates multiple fields on an opportunity record by passing any combination of field-value pairs in the request. Use this for complex updates involving several fields simultaneously, but prefer the specific single-field update tools (stage, amount, close_date) for focused changes to avoid unnecessary field overwrites.',
2748
+ inputSchema: {
2749
+ type: 'object',
2750
+ properties: {
2751
+ opportunity_id: { type: 'string' },
2752
+ fields: {
2753
+ type: 'object',
2754
+ description: 'JSON object of Opportunity fields to PATCH (e.g. StageName, Amount, CloseDate)',
2755
+ additionalProperties: true,
2756
+ },
2757
+ },
2758
+ required: ['opportunity_id', 'fields'],
2759
+ },
2760
+ handler: async ({ opportunity_id, fields }) => {
2761
+ await ensureAccessToken();
2762
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2763
+ try {
2764
+ const res = await axios.patch(
2765
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2766
+ fields,
2767
+ { headers, timeout: 60000, validateStatus: () => true }
2768
+ );
2769
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2770
+ return text(res.data && Object.keys(res.data).length ? res.data : { success: true });
2771
+ } catch (e) {
2772
+ handleError(e);
2773
+ }
2774
+ },
2775
+ },
2776
+ {
2777
+ name: 'update_opportunity_stage',
2778
+ description:
2779
+ 'Updates only the stage field of an opportunity (e.g., from \'Prospecting\' to \'Proposal\'), which is the most frequent sales action. Use this instead of the generic update_opportunity when only moving deals through the sales process to ensure clean stage progression tracking.',
2780
+ inputSchema: {
2781
+ type: 'object',
2782
+ properties: {
2783
+ opportunity_id: { type: 'string' },
2784
+ stage: { type: 'string', description: 'StageName value' },
2785
+ close_date: { type: 'string', description: 'Optional CloseDate (YYYY-MM-DD)' },
2786
+ probability: { type: 'number', description: 'Optional Probability percent' },
2787
+ },
2788
+ required: ['opportunity_id', 'stage'],
2789
+ },
2790
+ handler: async ({ opportunity_id, stage, close_date, probability }) => {
2791
+ const body = { StageName: stage };
2792
+ if (close_date != null) body.CloseDate = close_date;
2793
+ if (probability != null) body.Probability = probability;
2794
+ await ensureAccessToken();
2795
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2796
+ try {
2797
+ const res = await axios.patch(
2798
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2799
+ body,
2800
+ { headers, timeout: 60000, validateStatus: () => true }
2801
+ );
2802
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2803
+ return text({ success: true });
2804
+ } catch (e) {
2805
+ handleError(e);
2806
+ }
2807
+ },
2808
+ },
2809
+ {
2810
+ name: 'update_opportunity_amount',
2811
+ description:
2812
+ 'Updates only the Amount field of an opportunity to reflect changed deal value during negotiations. Use this focused tool instead of update_opportunity when deal size changes but other fields remain unchanged to maintain accurate opportunity history.',
2813
+ inputSchema: {
2814
+ type: 'object',
2815
+ properties: {
2816
+ opportunity_id: { type: 'string' },
2817
+ amount: { type: 'number' },
2818
+ },
2819
+ required: ['opportunity_id', 'amount'],
2820
+ },
2821
+ handler: async ({ opportunity_id, amount }) => {
2822
+ await ensureAccessToken();
2823
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2824
+ try {
2825
+ const res = await axios.patch(
2826
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2827
+ { Amount: amount },
2828
+ { headers, timeout: 60000, validateStatus: () => true }
2829
+ );
2830
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2831
+ return text({ success: true });
2832
+ } catch (e) {
2833
+ handleError(e);
2834
+ }
2835
+ },
2836
+ },
2837
+ {
2838
+ name: 'update_opportunity_close_date',
2839
+ description:
2840
+ 'Updates only the CloseDate field to reflect when an opportunity is expected to close, typically used when deals slip or accelerate. Use this instead of update_opportunity for timeline adjustments to preserve accurate forecasting data without touching other fields.',
2841
+ inputSchema: {
2842
+ type: 'object',
2843
+ properties: {
2844
+ opportunity_id: { type: 'string' },
2845
+ close_date: { type: 'string', description: 'CloseDate (YYYY-MM-DD)' },
2846
+ },
2847
+ required: ['opportunity_id', 'close_date'],
2848
+ },
2849
+ handler: async ({ opportunity_id, close_date }) => {
2850
+ await ensureAccessToken();
2851
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2852
+ try {
2853
+ const res = await axios.patch(
2854
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2855
+ { CloseDate: close_date },
2856
+ { headers, timeout: 60000, validateStatus: () => true }
2857
+ );
2858
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2859
+ return text({ success: true });
2860
+ } catch (e) {
2861
+ handleError(e);
2862
+ }
2863
+ },
2864
+ },
2865
+ {
2866
+ name: 'close_won_opportunity',
2867
+ description:
2868
+ 'Sets an opportunity to Closed Won with probability 100% and CloseDate set to today or the date you provide. Use when a deal is confirmed won instead of a generic stage update.',
2869
+ inputSchema: {
2870
+ type: 'object',
2871
+ properties: {
2872
+ opportunity_id: { type: 'string' },
2873
+ close_date: {
2874
+ type: 'string',
2875
+ description: 'Optional YYYY-MM-DD; defaults to today (UTC date)',
2876
+ },
2877
+ },
2878
+ required: ['opportunity_id'],
2879
+ },
2880
+ handler: async ({ opportunity_id, close_date }) => {
2881
+ const today = new Date().toISOString().slice(0, 10);
2882
+ const body = {
2883
+ StageName: 'Closed Won',
2884
+ Probability: 100,
2885
+ CloseDate: close_date || today,
2886
+ };
2887
+ await ensureAccessToken();
2888
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2889
+ try {
2890
+ const res = await axios.patch(
2891
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2892
+ body,
2893
+ { headers, timeout: 60000, validateStatus: () => true }
2894
+ );
2895
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2896
+ return text({ success: true });
2897
+ } catch (e) {
2898
+ handleError(e);
2899
+ }
2900
+ },
2901
+ },
2902
+ {
2903
+ name: 'close_lost_opportunity',
2904
+ description:
2905
+ 'Sets an opportunity to \'Closed Lost\' stage, automatically sets probability to 0%, and optionally captures the loss reason for reporting. Use this instead of manual updates when deals are confirmed lost to ensure complete loss tracking and proper probability reset.',
2906
+ inputSchema: {
2907
+ type: 'object',
2908
+ properties: {
2909
+ opportunity_id: { type: 'string' },
2910
+ loss_reason: { type: 'string', description: 'Optional custom loss reason field if configured' },
2911
+ },
2912
+ required: ['opportunity_id'],
2913
+ },
2914
+ handler: async ({ opportunity_id, loss_reason }) => {
2915
+ const body = { StageName: 'Closed Lost', Probability: 0 };
2916
+ if (loss_reason) {
2917
+ body.Description = loss_reason;
2918
+ }
2919
+ await ensureAccessToken();
2920
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
2921
+ try {
2922
+ const res = await axios.patch(
2923
+ `${getBaseUrl()}/sobjects/Opportunity/${encodeURIComponent(opportunity_id)}`,
2924
+ body,
2925
+ { headers, timeout: 60000, validateStatus: () => true }
2926
+ );
2927
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
2928
+ return text({ success: true });
2929
+ } catch (e) {
2930
+ handleError(e);
2931
+ }
2932
+ },
2933
+ },
2934
+ {
2935
+ name: 'run_soql_query',
2936
+ description:
2937
+ 'Executes any raw SOQL query and returns matching records with all requested fields, limited to 2000 records per call. Use when you need custom field combinations or complex WHERE clauses that the specialized search tools don\'t support, but prefer specific search tools for common lookups since they\'re simpler.',
2938
+ inputSchema: {
2939
+ type: 'object',
2940
+ properties: {
2941
+ query: { type: 'string', description: 'Raw SOQL string' },
2942
+ limit: {
2943
+ type: 'number',
2944
+ description:
2945
+ 'If the query has no LIMIT clause, append this LIMIT (optional). Leave unset if your query already includes LIMIT.',
2946
+ },
2947
+ },
2948
+ required: ['query'],
2949
+ },
2950
+ handler: async ({ query, limit }) => {
2951
+ let q = query.trim();
2952
+ if (limit != null && !/\blimit\s+\d+/i.test(q)) {
2953
+ q = `${q} LIMIT ${Number(limit)}`;
2954
+ }
2955
+ return text(await soqlGet(q));
2956
+ },
2957
+ },
2958
+ {
2959
+ name: 'run_soql_query_all_pages',
2960
+ description:
2961
+ 'Executes a SOQL query and automatically handles pagination to return the complete dataset of all matching records, regardless of size. Use when you need full data exports or comprehensive analysis where missing records would compromise results, but avoid for large datasets unless truly necessary due to performance impact.',
2962
+ inputSchema: {
2963
+ type: 'object',
2964
+ properties: {
2965
+ query: { type: 'string', description: 'SOQL without automatic LIMIT' },
2966
+ max_records: { type: 'number', description: 'Safety cap (default 10000)' },
2967
+ },
2968
+ required: ['query'],
2969
+ },
2970
+ handler: async ({ query, max_records }) => {
2971
+ const cap = max_records != null ? Number(max_records) : 10000;
2972
+ return text(await fetchQueryAllPages(query.trim(), cap));
2973
+ },
2974
+ },
2975
+ {
2976
+ name: 'search_accounts',
2977
+ description:
2978
+ 'Searches Account records by name, industry, type, or location fields and returns basic account information including IDs. Use this instead of writing SOQL for common account lookups, then use the returned ID with get_account for full record details.',
2979
+ inputSchema: {
2980
+ type: 'object',
2981
+ properties: {
2982
+ name: { type: 'string' },
2983
+ industry: { type: 'string' },
2984
+ type: { type: 'string' },
2985
+ city: { type: 'string', description: 'BillingCity' },
2986
+ limit: { type: 'number', description: 'Default 20' },
2987
+ },
2988
+ required: [],
2989
+ },
2990
+ handler: async (p) => {
2991
+ const lim = p.limit != null ? Number(p.limit) : 20;
2992
+ const where = buildSearchWhere({
2993
+ Name: p.name,
2994
+ Industry: p.industry,
2995
+ Type: p.type,
2996
+ BillingCity: p.city,
2997
+ });
2998
+ if (!where) {
2999
+ throw new Error('Provide at least one of name, industry, type, or city');
3000
+ }
3001
+ const q = `SELECT Id,Name,Industry,Type,BillingCity FROM Account WHERE ${where} LIMIT ${lim}`;
3002
+ return text(await soqlGet(q));
3003
+ },
3004
+ },
3005
+ {
3006
+ name: 'search_contacts',
3007
+ description:
3008
+ 'Searches Contact records by name, email, phone, or associated account and returns basic contact information including IDs. Use this for finding contacts without writing SOQL, then retrieve full contact details using the returned ID with direct read operations.',
3009
+ inputSchema: {
3010
+ type: 'object',
3011
+ properties: {
3012
+ name: { type: 'string', description: 'Matches FirstName or LastName' },
3013
+ email: { type: 'string' },
3014
+ phone: { type: 'string' },
3015
+ account_name: { type: 'string', description: 'Account.Name via subquery filter — uses AccountId in LIKE' },
3016
+ title: { type: 'string' },
3017
+ limit: { type: 'number' },
3018
+ },
3019
+ required: [],
3020
+ },
3021
+ handler: async (p) => {
3022
+ const lim = p.limit != null ? Number(p.limit) : 20;
3023
+ const parts = [];
3024
+ if (p.name) {
3025
+ const x = escapeSoqlString(p.name);
3026
+ parts.push(`(FirstName LIKE '%${x}%' OR LastName LIKE '%${x}%')`);
3027
+ }
3028
+ if (p.email) parts.push(`Email LIKE '%${escapeSoqlString(p.email)}%'`);
3029
+ if (p.phone) parts.push(`Phone LIKE '%${escapeSoqlString(p.phone)}%'`);
3030
+ if (p.title) parts.push(`Title LIKE '%${escapeSoqlString(p.title)}%'`);
3031
+ if (p.account_name) {
3032
+ parts.push(
3033
+ `AccountId IN (SELECT Id FROM Account WHERE Name LIKE '%${escapeSoqlString(p.account_name)}%')`
3034
+ );
3035
+ }
3036
+ if (!parts.length) throw new Error('Provide at least one search criterion');
3037
+ const q = `SELECT Id,FirstName,LastName,Email,Phone,Title,AccountId FROM Contact WHERE ${parts.join(' AND ')} LIMIT ${lim}`;
3038
+ return text(await soqlGet(q));
3039
+ },
3040
+ },
3041
+ {
3042
+ name: 'search_leads',
3043
+ description:
3044
+ 'Searches Lead records by name, company, email, or status fields and returns basic lead information including IDs. Use this to locate leads without SOQL knowledge, then use get_lead with the returned ID for complete lead record access.',
3045
+ inputSchema: {
3046
+ type: 'object',
3047
+ properties: {
3048
+ name: { type: 'string' },
3049
+ company: { type: 'string' },
3050
+ email: { type: 'string' },
3051
+ status: { type: 'string' },
3052
+ lead_source: { type: 'string' },
3053
+ limit: { type: 'number' },
3054
+ },
3055
+ required: [],
3056
+ },
3057
+ handler: async (p) => {
3058
+ const lim = p.limit != null ? Number(p.limit) : 20;
3059
+ const parts = [];
3060
+ if (p.name) {
3061
+ const x = escapeSoqlString(p.name);
3062
+ parts.push(`(FirstName LIKE '%${x}%' OR LastName LIKE '%${x}%')`);
3063
+ }
3064
+ if (p.company) parts.push(`Company LIKE '%${escapeSoqlString(p.company)}%'`);
3065
+ if (p.email) parts.push(`Email LIKE '%${escapeSoqlString(p.email)}%'`);
3066
+ if (p.status) parts.push(`Status LIKE '%${escapeSoqlString(p.status)}%'`);
3067
+ if (p.lead_source) parts.push(`LeadSource LIKE '%${escapeSoqlString(p.lead_source)}%'`);
3068
+ if (!parts.length) throw new Error('Provide at least one search criterion');
3069
+ const q = `SELECT Id,FirstName,LastName,Company,Email,Status,LeadSource FROM Lead WHERE ${parts.join(' AND ')} LIMIT ${lim}`;
3070
+ return text(await soqlGet(q));
3071
+ },
3072
+ },
3073
+ {
3074
+ name: 'search_opportunities',
3075
+ description:
3076
+ 'Searches Opportunity records by stage, amount, close date, or other criteria and returns matching opportunities with key sales fields. Use this for finding deals by sales criteria without writing SOQL, ideal for pipeline analysis and deal research.',
3077
+ inputSchema: {
3078
+ type: 'object',
3079
+ properties: {
3080
+ name: { type: 'string' },
3081
+ account_name: { type: 'string' },
3082
+ stage: { type: 'string', description: 'StageName' },
3083
+ min_amount: { type: 'number' },
3084
+ max_amount: { type: 'number' },
3085
+ closing_before: { type: 'string', description: 'CloseDate < date (YYYY-MM-DD)' },
3086
+ closing_after: { type: 'string', description: 'CloseDate > date' },
3087
+ limit: { type: 'number' },
3088
+ },
3089
+ required: [],
3090
+ },
3091
+ handler: async (p) => {
3092
+ const lim = p.limit != null ? Number(p.limit) : 20;
3093
+ const parts = [];
3094
+ if (p.name) parts.push(`Name LIKE '%${escapeSoqlString(p.name)}%'`);
3095
+ if (p.account_name) {
3096
+ parts.push(
3097
+ `AccountId IN (SELECT Id FROM Account WHERE Name LIKE '%${escapeSoqlString(p.account_name)}%')`
3098
+ );
3099
+ }
3100
+ if (p.stage) parts.push(`StageName LIKE '%${escapeSoqlString(p.stage)}%'`);
3101
+ if (p.min_amount != null) parts.push(`Amount >= ${Number(p.min_amount)}`);
3102
+ if (p.max_amount != null) parts.push(`Amount <= ${Number(p.max_amount)}`);
3103
+ if (p.closing_before) parts.push(`CloseDate < ${escapeSoqlString(p.closing_before)}`);
3104
+ if (p.closing_after) parts.push(`CloseDate > ${escapeSoqlString(p.closing_after)}`);
3105
+ if (!parts.length) throw new Error('Provide at least one search criterion');
3106
+ const q = `SELECT Id,Name,StageName,Amount,CloseDate,AccountId FROM Opportunity WHERE ${parts.join(' AND ')} LIMIT ${lim}`;
3107
+ return text(await soqlGet(q));
3108
+ },
3109
+ },
3110
+ {
3111
+ name: 'get_pipeline_summary',
3112
+ description:
3113
+ 'Returns an aggregated view of the sales pipeline showing deal count and total value grouped by opportunity stage. Use this for quick pipeline health checks and forecasting instead of writing complex GROUP BY SOQL queries.',
3114
+ inputSchema: {
3115
+ type: 'object',
3116
+ properties: {
3117
+ owner_id: { type: 'string', description: 'Optional OwnerId filter' },
3118
+ period: {
3119
+ type: 'string',
3120
+ enum: ['this_month', 'this_quarter', 'next_quarter'],
3121
+ description: 'Filters on CloseDate relative period (open opps)',
3122
+ },
3123
+ },
3124
+ required: [],
3125
+ },
3126
+ handler: async ({ owner_id, period }) => {
3127
+ const per = period || 'this_month';
3128
+ const clauses = ['IsClosed = false', pipelinePeriodClause(per)];
3129
+ if (owner_id) clauses.push(`OwnerId = '${escapeSoqlString(owner_id)}'`);
3130
+ const q = `SELECT StageName, COUNT(Id), SUM(Amount) FROM Opportunity WHERE ${clauses.join(' AND ')} GROUP BY StageName`;
3131
+ return text(await soqlGet(q));
3132
+ },
3133
+ },
3134
+ {
3135
+ name: 'global_search',
3136
+ description:
3137
+ 'Runs SOSL across Accounts, Contacts, Leads, and Opportunities and returns matches from each type. Use when you have a name, email, or keyword but are not sure which object holds the record.',
3138
+ inputSchema: {
3139
+ type: 'object',
3140
+ properties: {
3141
+ search_term: { type: 'string' },
3142
+ objects: {
3143
+ type: 'array',
3144
+ items: { type: 'string' },
3145
+ description:
3146
+ 'Optional API names to RETURNING (default Account,Contact,Lead,Opportunity)',
3147
+ },
3148
+ limit: { type: 'number', description: 'Per-object row limit (default 10)' },
3149
+ },
3150
+ required: ['search_term'],
3151
+ },
3152
+ handler: async ({ search_term, objects, limit }) => {
3153
+ const lim = limit != null ? Number(limit) : 10;
3154
+ const term = escapeSoqlString(search_term);
3155
+ const objs = Array.isArray(objects) && objects.length
3156
+ ? objects
3157
+ : ['Account', 'Contact', 'Lead', 'Opportunity'];
3158
+ const returning = objs.map((o) => `${o}(Id,Name)`).join(',');
3159
+ const sosl = `FIND {${term}} IN ALL FIELDS RETURNING ${returning} LIMIT ${lim}`;
3160
+ await ensureAccessToken();
3161
+ const headers = { ...buildAuthHeaders(), Accept: 'application/json' };
3162
+ const url = `${getBaseUrl()}/search/?q=${encodeURIComponent(sosl)}`;
3163
+ try {
3164
+ const res = await axios.get(url, { headers, timeout: 60000 });
3165
+ return text(res.data);
3166
+ } catch (e) {
3167
+ handleError(e);
3168
+ }
3169
+ },
3170
+ },
3171
+ {
3172
+ name: 'create_task',
3173
+ description: 'Creates a new Task record with complete control over all available fields including subject, description, priority, due date, and related record associations. Use when you need to set specific task fields that the specialized task creation tools don\'t support.',
3174
+ inputSchema: {
3175
+ type: 'object',
3176
+ properties: {
3177
+ fields: {
3178
+ type: 'object',
3179
+ description:
3180
+ 'Task fields (Subject, ActivityDate, OwnerId, WhoId, WhatId, Status, Priority, Description, etc.)',
3181
+ additionalProperties: true,
3182
+ },
3183
+ },
3184
+ required: ['fields'],
3185
+ },
3186
+ handler: async ({ fields }) => {
3187
+ await ensureAccessToken();
3188
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3189
+ try {
3190
+ const res = await axios.post(`${getBaseUrl()}/sobjects/Task`, fields, {
3191
+ headers,
3192
+ timeout: 60000,
3193
+ validateStatus: () => true,
3194
+ });
3195
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3196
+ return text(res.data);
3197
+ } catch (e) {
3198
+ handleError(e);
3199
+ }
3200
+ },
3201
+ },
3202
+ {
3203
+ name: 'log_call',
3204
+ description:
3205
+ 'Creates a Task record specifically for logging completed phone calls, automatically setting Type=\'Call\' and Status=\'Completed\' while linking to a Contact or Lead. Use this after sales or support calls instead of generic task creation since it pre-fills call-specific fields and ensures proper activity tracking.',
3206
+ inputSchema: {
3207
+ type: 'object',
3208
+ properties: {
3209
+ subject: { type: 'string' },
3210
+ who_id: { type: 'string', description: 'WhoId (Contact or Lead Id)' },
3211
+ what_id: { type: 'string', description: 'Optional WhatId (Account, Opportunity, etc.)' },
3212
+ description: { type: 'string' },
3213
+ duration_minutes: { type: 'number' },
3214
+ call_disposition: { type: 'string' },
3215
+ call_type: { type: 'string' },
3216
+ },
3217
+ required: ['subject'],
3218
+ },
3219
+ handler: async (p) => {
3220
+ const task = {
3221
+ Subject: p.subject,
3222
+ Type: 'Call',
3223
+ Status: 'Completed',
3224
+ TaskSubtype: 'Call',
3225
+ };
3226
+ if (p.who_id) task.WhoId = p.who_id;
3227
+ if (p.what_id) task.WhatId = p.what_id;
3228
+ if (p.description) task.Description = p.description;
3229
+ if (p.duration_minutes != null) task.CallDurationInSeconds = Number(p.duration_minutes) * 60;
3230
+ if (p.call_disposition) task.CallDisposition = p.call_disposition;
3231
+ if (p.call_type) task.CallType = p.call_type;
3232
+ await ensureAccessToken();
3233
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3234
+ try {
3235
+ const res = await axios.post(`${getBaseUrl()}/sobjects/Task`, task, {
3236
+ headers,
3237
+ timeout: 60000,
3238
+ validateStatus: () => true,
3239
+ });
3240
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3241
+ return text(res.data);
3242
+ } catch (e) {
3243
+ handleError(e);
3244
+ }
3245
+ },
3246
+ },
3247
+ {
3248
+ name: 'log_email',
3249
+ description:
3250
+ 'Creates an EmailMessage activity record in Salesforce to log outbound emails sent to Contacts or Leads, preserving email communication history in the CRM. Use this instead of manual activity logging when you need to formally document important email correspondence for compliance or sales tracking purposes.',
3251
+ inputSchema: {
3252
+ type: 'object',
3253
+ properties: {
3254
+ subject: { type: 'string' },
3255
+ to_address: { type: 'string' },
3256
+ who_id: { type: 'string', description: 'Related Contact or Lead Id' },
3257
+ what_id: { type: 'string', description: 'Optional related record Id' },
3258
+ body: { type: 'string' },
3259
+ sent_date: { type: 'string', description: 'ISO or Salesforce datetime string' },
3260
+ },
3261
+ required: ['subject'],
3262
+ },
3263
+ handler: async (p) => {
3264
+ const msg = {
3265
+ Subject: p.subject,
3266
+ Status: '3',
3267
+ Incoming: false,
3268
+ TextBody: p.body || '',
3269
+ };
3270
+ if (p.to_address) msg.ToAddress = p.to_address;
3271
+ if (p.sent_date) msg.MessageDate = p.sent_date;
3272
+ if (p.what_id) msg.RelatedToId = p.what_id;
3273
+ else if (p.who_id) msg.RelatedToId = p.who_id;
3274
+ await ensureAccessToken();
3275
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3276
+ try {
3277
+ const res = await axios.post(`${getBaseUrl()}/sobjects/EmailMessage`, msg, {
3278
+ headers,
3279
+ timeout: 60000,
3280
+ validateStatus: () => true,
3281
+ });
3282
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3283
+ return text(res.data);
3284
+ } catch (e) {
3285
+ handleError(e);
3286
+ }
3287
+ },
3288
+ },
3289
+ {
3290
+ name: 'create_followup_task',
3291
+ description:
3292
+ 'Creates a Task record with a specified due date and links it to a Contact, Lead, or Opportunity to set follow-up reminders. This is the primary tool for sales workflow management - use after any customer interaction to ensure timely follow-up and avoid missed opportunities.',
3293
+ inputSchema: {
3294
+ type: 'object',
3295
+ properties: {
3296
+ subject: { type: 'string' },
3297
+ who_id: { type: 'string' },
3298
+ what_id: { type: 'string' },
3299
+ due_date: { type: 'string', description: 'ActivityDate (YYYY-MM-DD)' },
3300
+ priority: { type: 'string' },
3301
+ assigned_to_id: { type: 'string', description: 'OwnerId' },
3302
+ description: { type: 'string' },
3303
+ },
3304
+ required: ['subject', 'due_date'],
3305
+ },
3306
+ handler: async (p) => {
3307
+ const task = {
3308
+ Subject: p.subject,
3309
+ ActivityDate: p.due_date,
3310
+ Status: 'Not Started',
3311
+ };
3312
+ if (p.who_id) task.WhoId = p.who_id;
3313
+ if (p.what_id) task.WhatId = p.what_id;
3314
+ if (p.priority) task.Priority = p.priority;
3315
+ if (p.assigned_to_id) task.OwnerId = p.assigned_to_id;
3316
+ if (p.description) task.Description = p.description;
3317
+ await ensureAccessToken();
3318
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3319
+ try {
3320
+ const res = await axios.post(`${getBaseUrl()}/sobjects/Task`, task, {
3321
+ headers,
3322
+ timeout: 60000,
3323
+ validateStatus: () => true,
3324
+ });
3325
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3326
+ return text(res.data);
3327
+ } catch (e) {
3328
+ handleError(e);
3329
+ }
3330
+ },
3331
+ },
3332
+ {
3333
+ name: 'get_todays_tasks',
3334
+ description:
3335
+ 'Retrieves all open Task records assigned to the current user with today\'s due date, with an option to include overdue tasks for comprehensive daily planning. Use this at the beginning of each workday to get a prioritized list of follow-ups and activities that need immediate attention.',
3336
+ inputSchema: {
3337
+ type: 'object',
3338
+ properties: {
3339
+ user_id: {
3340
+ type: 'string',
3341
+ description: 'OwnerId — defaults to connected user from login',
3342
+ },
3343
+ include_overdue: {
3344
+ type: 'boolean',
3345
+ description: 'If true, includes tasks with ActivityDate < TODAY',
3346
+ },
3347
+ },
3348
+ required: [],
3349
+ },
3350
+ handler: async ({ user_id, include_overdue }) => {
3351
+ const uid = user_id || getDefaultUserId();
3352
+ if (!uid) {
3353
+ throw new Error('user_id required (no user id stored from OAuth — pass user_id explicitly)');
3354
+ }
3355
+ const dateClause = include_overdue
3356
+ ? '(ActivityDate = TODAY OR ActivityDate < TODAY)'
3357
+ : 'ActivityDate = TODAY';
3358
+ const q = `SELECT Id,Subject,ActivityDate,Status,Priority,WhoId,WhatId FROM Task WHERE OwnerId='${escapeSoqlString(uid)}' AND IsClosed=false AND ${dateClause} ORDER BY ActivityDate ASC`;
3359
+ return text(await soqlGet(q));
3360
+ },
3361
+ },
3362
+ {
3363
+ name: 'get_overdue_tasks',
3364
+ description:
3365
+ 'Retrieves all open Task records assigned to the current user that are past their due date, helping identify missed follow-ups requiring urgent action. Use this specifically when you need to focus on overdue items rather than today\'s full task list from get_todays_tasks.',
3366
+ inputSchema: {
3367
+ type: 'object',
3368
+ properties: {
3369
+ user_id: { type: 'string' },
3370
+ limit: { type: 'number', description: 'Default 50' },
3371
+ },
3372
+ required: [],
3373
+ },
3374
+ handler: async ({ user_id, limit }) => {
3375
+ const uid = user_id || getDefaultUserId();
3376
+ if (!uid) {
3377
+ throw new Error('user_id required (no user id stored from OAuth — pass user_id explicitly)');
3378
+ }
3379
+ const lim = limit != null ? Number(limit) : 50;
3380
+ const q = `SELECT Id,Subject,ActivityDate,Status,Priority FROM Task WHERE OwnerId='${escapeSoqlString(uid)}' AND IsClosed=false AND ActivityDate < TODAY ORDER BY ActivityDate ASC LIMIT ${lim}`;
3381
+ return text(await soqlGet(q));
3382
+ },
3383
+ },
3384
+ {
3385
+ name: 'update_lead',
3386
+ description: 'Updates multiple fields on a Lead record in a single operation, allowing comprehensive lead data modification including contact details, company information, and custom fields. Use this for bulk lead updates rather than update_lead_status when you need to modify fields beyond just the lead status.',
3387
+ inputSchema: {
3388
+ type: 'object',
3389
+ properties: {
3390
+ lead_id: { type: 'string' },
3391
+ fields: { type: 'object', additionalProperties: true },
3392
+ },
3393
+ required: ['lead_id', 'fields'],
3394
+ },
3395
+ handler: async ({ lead_id, fields }) => {
3396
+ await ensureAccessToken();
3397
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3398
+ try {
3399
+ const res = await axios.patch(
3400
+ `${getBaseUrl()}/sobjects/Lead/${encodeURIComponent(lead_id)}`,
3401
+ fields,
3402
+ { headers, timeout: 60000, validateStatus: () => true }
3403
+ );
3404
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3405
+ return text({ success: true });
3406
+ } catch (e) {
3407
+ handleError(e);
3408
+ }
3409
+ },
3410
+ },
3411
+ {
3412
+ name: 'update_lead_status',
3413
+ description:
3414
+ 'Updates only the Status field on a Lead record to move it through the qualification pipeline (New, Working, Qualified, etc.). Use this focused tool instead of update_lead when you only need to change lead status, as it\'s more efficient and maintains proper status workflow validation.',
3415
+ inputSchema: {
3416
+ type: 'object',
3417
+ properties: {
3418
+ lead_id: { type: 'string' },
3419
+ status: {
3420
+ type: 'string',
3421
+ description: 'Status picklist value (e.g. New, Working, Nurturing, Qualified, Unqualified)',
3422
+ },
3423
+ },
3424
+ required: ['lead_id', 'status'],
3425
+ },
3426
+ handler: async ({ lead_id, status }) => {
3427
+ await ensureAccessToken();
3428
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3429
+ try {
3430
+ const res = await axios.patch(
3431
+ `${getBaseUrl()}/sobjects/Lead/${encodeURIComponent(lead_id)}`,
3432
+ { Status: status },
3433
+ { headers, timeout: 60000, validateStatus: () => true }
3434
+ );
3435
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3436
+ return text({ success: true });
3437
+ } catch (e) {
3438
+ handleError(e);
3439
+ }
3440
+ },
3441
+ },
3442
+ {
3443
+ name: 'convert_lead',
3444
+ description:
3445
+ 'Transforms a qualified Lead into separate Account, Contact, and optionally Opportunity records, completing the lead-to-customer conversion process. This is a one-way operation that should only be used when a lead is fully sales-qualified, as it fundamentally changes the data structure and cannot be easily reversed.',
3446
+ inputSchema: {
3447
+ type: 'object',
3448
+ properties: {
3449
+ lead_id: { type: 'string' },
3450
+ create_opportunity: { type: 'boolean', description: 'Default true' },
3451
+ opportunity_name: { type: 'string' },
3452
+ account_id: { type: 'string' },
3453
+ contact_id: { type: 'string' },
3454
+ converted_status: {
3455
+ type: 'string',
3456
+ description: 'Converted status label (org-specific)',
3457
+ },
3458
+ },
3459
+ required: ['lead_id'],
3460
+ },
3461
+ handler: async (p) => {
3462
+ const createOpp = p.create_opportunity !== false;
3463
+ const inputs = [
3464
+ {
3465
+ leadId: p.lead_id,
3466
+ convertedStatus: p.converted_status || 'Qualified - Converted',
3467
+ doNotCreateOpportunity: !createOpp,
3468
+ opportunityName: createOpp ? p.opportunity_name || null : null,
3469
+ accountId: p.account_id || null,
3470
+ contactId: p.contact_id || null,
3471
+ sendEmailToOwner: false,
3472
+ },
3473
+ ];
3474
+ await ensureAccessToken();
3475
+ const headers = { ...buildAuthHeaders(), 'Content-Type': 'application/json' };
3476
+ const url = `${getBaseUrl()}/sobjects/Lead/${encodeURIComponent(p.lead_id)}/actions/standard/convert`;
3477
+ try {
3478
+ const res = await axios.post(url, { inputs }, { headers, timeout: 120000, validateStatus: () => true });
3479
+ if (res.status < 200 || res.status >= 300) handleError({ response: res });
3480
+ return text(res.data);
3481
+ } catch (e) {
3482
+ handleError(e);
3483
+ }
3484
+ },
3485
+ },
3486
+ ];
3487
+ }
3488
+
3489
+ function connectSmartTools() {
3490
+ return [
3491
+ {
3492
+ name: 'post_chatter_message',
3493
+ description:
3494
+ 'Creates a new post on a Salesforce Chatter feed for a specific record, user, or group, enabling team collaboration and @mentions within the CRM. Use this to share updates, ask questions, or notify team members about important record changes directly in the Salesforce social collaboration layer.',
3495
+ inputSchema: {
3496
+ type: 'object',
3497
+ properties: {
3498
+ feed_element_type: {
3499
+ type: 'string',
3500
+ description: 'Usually FeedItem for a standard post.',
3501
+ default: 'FeedItem',
3502
+ },
3503
+ subject_id: {
3504
+ type: 'string',
3505
+ description: 'Record, group or user ID the feed belongs to.',
3506
+ },
3507
+ message_text: {
3508
+ type: 'string',
3509
+ description: 'Plain text body (before @mentions).',
3510
+ },
3511
+ mentions: {
3512
+ type: 'array',
3513
+ items: { type: 'string' },
3514
+ description: 'Salesforce user IDs to @mention (order is appended after message_text).',
3515
+ },
3516
+ },
3517
+ required: ['subject_id', 'feed_element_type'],
3518
+ },
3519
+ handler: async (p) => {
3520
+ const feedElementType = p.feed_element_type || 'FeedItem';
3521
+ const subjectId = p.subject_id;
3522
+ const textPart = p.message_text != null ? String(p.message_text) : '';
3523
+ const mentionIds = Array.isArray(p.mentions) ? p.mentions.filter(Boolean) : [];
3524
+ if (!textPart.trim() && mentionIds.length === 0) {
3525
+ throw new Error('Provide message_text and/or mentions');
3526
+ }
3527
+ const messageSegments = [];
3528
+ if (textPart.length) messageSegments.push({ type: 'Text', text: textPart });
3529
+ for (const id of mentionIds) {
3530
+ messageSegments.push({ type: 'Mention', id: String(id) });
3531
+ }
3532
+ const body = {
3533
+ feedElementType,
3534
+ subjectId,
3535
+ body: { messageSegments },
3536
+ };
3537
+ const data = await sfRequest('POST', '/chatter/feed-elements', 'versioned', body);
3538
+ return text(data);
3539
+ },
3540
+ },
3541
+ {
3542
+ name: 'get_record_feed',
3543
+ description:
3544
+ 'Retrieves all Chatter feed items (posts, comments, file shares) associated with a specific Salesforce record like Account, Opportunity, or Case. Use this to review the complete collaboration history and team communications related to a particular customer or deal.',
3545
+ inputSchema: {
3546
+ type: 'object',
3547
+ properties: {
3548
+ record_id: { type: 'string' },
3549
+ page_size: { type: 'number', description: 'Default 25', default: 25 },
3550
+ },
3551
+ required: ['record_id'],
3552
+ },
3553
+ handler: async ({ record_id, page_size }) => {
3554
+ const ps = page_size != null && Number.isFinite(Number(page_size)) ? Number(page_size) : 25;
3555
+ const data = await sfRequest(
3556
+ 'GET',
3557
+ '/chatter/feeds/record/{RECORD_GROUP_ID}/feed-elements',
3558
+ 'versioned',
3559
+ { RECORD_GROUP_ID: record_id, pageSize: ps }
3560
+ );
3561
+ return text(data);
3562
+ },
3563
+ },
3564
+ {
3565
+ name: 'upload_file_to_record',
3566
+ description:
3567
+ 'Uploads a file from your local system and attaches it directly to any Salesforce record using the Files Connect API, creating a ContentDocument relationship. Use this to attach contracts, proposals, images, or other documents to Accounts, Opportunities, Cases, or any custom object record.',
3568
+ inputSchema: {
3569
+ type: 'object',
3570
+ properties: {
3571
+ record_id: { type: 'string' },
3572
+ file_name: { type: 'string' },
3573
+ file_content_base64: { type: 'string', description: 'File bytes as base64 (no data: URL prefix).' },
3574
+ description: {
3575
+ type: 'string',
3576
+ description: 'Optional feed text shown with the attachment.',
3577
+ },
3578
+ },
3579
+ required: ['record_id', 'file_name', 'file_content_base64'],
3580
+ },
3581
+ handler: async ({ record_id, file_name, file_content_base64, description }) => {
3582
+ await ensureAccessToken();
3583
+ const headers = buildAuthHeaders();
3584
+ const buf = Buffer.from(String(file_content_base64), 'base64');
3585
+ const form = new FormData();
3586
+ form.append('fileData', new Blob([buf]), file_name);
3587
+ const upUrl = `${getBaseUrl()}/connect/files/users/me`;
3588
+ let upRes;
3589
+ try {
3590
+ upRes = await fetch(upUrl, { method: 'POST', headers, body: form });
3591
+ } catch (e) {
3592
+ throw new Error(e?.message || 'File upload request failed');
3593
+ }
3594
+ const upText = await upRes.text();
3595
+ let fileMeta;
3596
+ try {
3597
+ fileMeta = upText ? JSON.parse(upText) : {};
3598
+ } catch {
3599
+ throw new Error(`Upload response not JSON (${upRes.status}): ${upText.slice(0, 500)}`);
3600
+ }
3601
+ if (!upRes.ok) {
3602
+ handleError({ response: { status: upRes.status, data: fileMeta, statusText: upRes.statusText } });
3603
+ }
3604
+ const fileId = fileMeta.id;
3605
+ if (!fileId) throw new Error('Upload succeeded but no file id in response');
3606
+ const feedBody = {
3607
+ feedElementType: 'FeedItem',
3608
+ subjectId: record_id,
3609
+ capabilities: { files: { items: [{ id: fileId }] } },
3610
+ };
3611
+ if (description != null && String(description).trim()) {
3612
+ feedBody.body = {
3613
+ messageSegments: [{ type: 'Text', text: String(description).trim() }],
3614
+ };
3615
+ }
3616
+ const data = await sfRequest('POST', '/chatter/feed-elements', 'versioned', feedBody);
3617
+ return text({ upload: fileMeta, feedElement: data });
3618
+ },
3619
+ },
3620
+ {
3621
+ name: 'get_user_profile',
3622
+ description:
3623
+ 'Loads Chatter user profile (name, title, department, email, photo URL) for context before assignments or handoffs.',
3624
+ inputSchema: {
3625
+ type: 'object',
3626
+ properties: {
3627
+ user_id: {
3628
+ type: 'string',
3629
+ description: 'User id or me for the connected user (default me).',
3630
+ },
3631
+ },
3632
+ required: [],
3633
+ },
3634
+ handler: async ({ user_id }) => {
3635
+ const uid =
3636
+ user_id === undefined || user_id === null || String(user_id).trim() === ''
3637
+ ? 'me'
3638
+ : String(user_id).trim();
3639
+ const path =
3640
+ uid === 'me' ? '/chatter/users/me' : `/chatter/users/${encodeURIComponent(uid)}`;
3641
+ const data = await sfRequest('GET', path, 'versioned', {});
3642
+ return text(data);
3643
+ },
3644
+ },
3645
+ {
3646
+ name: 'list_groups',
3647
+ description:
3648
+ 'Returns Chatter groups that the current user belongs to or searches all groups by name, providing group IDs and metadata needed for posting to group feeds. Use this before posting content to groups to ensure proper group targeting and access permissions.',
3649
+ inputSchema: {
3650
+ type: 'object',
3651
+ properties: {
3652
+ query: { type: 'string', description: 'Optional search string (group name).' },
3653
+ limit: { type: 'number', description: 'Default 25 (pageSize).', default: 25 },
3654
+ },
3655
+ required: [],
3656
+ },
3657
+ handler: async ({ query, limit }) => {
3658
+ const pageSize = limit != null && Number.isFinite(Number(limit)) ? Number(limit) : 25;
3659
+ const params = { pageSize };
3660
+ if (query != null && String(query).trim() !== '') params.q = String(query).trim();
3661
+ const data = await sfRequest('GET', '/chatter/groups', 'versioned', params);
3662
+ return text(data);
3663
+ },
3664
+ },
3665
+ ];
3666
+ }
3667
+
3668
+ function toolingSmartTools() {
3669
+ return [
3670
+ {
3671
+ name: 'execute_apex',
3672
+ description:
3673
+ 'Executes anonymous Apex code directly against the Salesforce org and returns compilation results, debug log pointers, and any execution errors without requiring deployment. Ideal for testing code snippets, running one-off data fixes, or prototyping custom logic using the Tooling API\'s executeAnonymous endpoint.',
3674
+ inputSchema: {
3675
+ type: 'object',
3676
+ properties: {
3677
+ apex_body: {
3678
+ type: 'string',
3679
+ description: 'Apex source to run anonymously (e.g. `System.debug(\\\'x\\\');`).',
3680
+ },
3681
+ },
3682
+ required: ['apex_body'],
3683
+ },
3684
+ handler: async ({ apex_body }) => {
3685
+ const data = await sfRequest('GET', '/executeAnonymous', 'tooling', {
3686
+ anonymousBody: apex_body,
3687
+ });
3688
+ return text(data);
3689
+ },
3690
+ },
3691
+ {
3692
+ name: 'get_apex_logs',
3693
+ description:
3694
+ 'Retrieves recent Apex debug logs from the organization to help diagnose failed integrations, custom code execution issues, or performance problems. Use after executing Apex code or when troubleshooting unexpected behavior in custom development.',
3695
+ inputSchema: {
3696
+ type: 'object',
3697
+ properties: {
3698
+ limit: { type: 'number', description: 'Default 10', default: 10 },
3699
+ user_id: {
3700
+ type: 'string',
3701
+ description: 'Optional: filter logs to this Salesforce user Id (LogUserId).',
3702
+ },
3703
+ },
3704
+ required: [],
3705
+ },
3706
+ handler: async ({ limit, user_id }) => {
3707
+ const limRaw = limit != null && Number.isFinite(Number(limit)) ? Number(limit) : 10;
3708
+ const lim = Math.max(1, Math.min(2000, Math.trunc(limRaw)));
3709
+ let q =
3710
+ 'SELECT Id, Application, DurationMilliseconds, Location, LogLength, LogUserId, Operation, Request, StartTime, Status FROM ApexLog';
3711
+ if (user_id != null && String(user_id).trim() !== '') {
3712
+ q += ` WHERE LogUserId='${escapeSoqlString(String(user_id).trim())}'`;
3713
+ }
3714
+ q += ` ORDER BY StartTime DESC LIMIT ${lim}`;
3715
+ const data = await sfRequest('GET', '/query', 'tooling', { q });
3716
+ return text(data);
3717
+ },
3718
+ },
3719
+ {
3720
+ name: 'run_apex_tests',
3721
+ description:
3722
+ 'Triggers asynchronous execution of Apex test classes via the Tooling API and returns the job ID for polling test results. Essential for validating org health and code coverage before deployments, though you\'ll need to poll separately for completion status.',
3723
+ inputSchema: {
3724
+ type: 'object',
3725
+ properties: {
3726
+ class_names: {
3727
+ type: 'array',
3728
+ items: { type: 'string' },
3729
+ description: 'Apex test class API names (e.g. MyClassTest).',
3730
+ },
3731
+ },
3732
+ required: ['class_names'],
3733
+ },
3734
+ handler: async ({ class_names }) => {
3735
+ const names = Array.isArray(class_names) ? class_names.map((s) => String(s).trim()).filter(Boolean) : [];
3736
+ if (!names.length) throw new Error('class_names must include at least one class name');
3737
+ const data = await sfRequest('POST', '/runTestsAsynchronous', 'tooling', {
3738
+ classNames: names.join(','),
3739
+ });
3740
+ return text(data);
3741
+ },
3742
+ },
3743
+ {
3744
+ name: 'query_tooling_api',
3745
+ description:
3746
+ 'Executes SOQL queries against Tooling API metadata objects like ApexClass, CustomField, ValidationRule, and other development artifacts. Use this instead of standard SOQL when you need to inspect org configuration, custom code, or metadata rather than business data.',
3747
+ inputSchema: {
3748
+ type: 'object',
3749
+ properties: {
3750
+ query: { type: 'string', description: 'Full SOQL string (Tooling entities only).' },
3751
+ },
3752
+ required: ['query'],
3753
+ },
3754
+ handler: async ({ query }) => {
3755
+ const data = await sfRequest('GET', '/query', 'tooling', { q: query });
3756
+ return text(data);
3757
+ },
3758
+ },
3759
+ {
3760
+ name: 'list_apex_classes',
3761
+ description:
3762
+ 'Returns a list of all Apex classes in the org with their names, compilation status, and last modified dates. Use this to discover available custom code before executing methods or understanding the org\'s development landscape.',
3763
+ inputSchema: {
3764
+ type: 'object',
3765
+ properties: {
3766
+ name_filter: {
3767
+ type: 'string',
3768
+ description: 'Optional substring match on Name (SOQL LIKE).',
3769
+ },
3770
+ limit: { type: 'number', description: 'Default 50', default: 50 },
3771
+ },
3772
+ required: [],
3773
+ },
3774
+ handler: async ({ name_filter, limit }) => {
3775
+ const limRaw = limit != null && Number.isFinite(Number(limit)) ? Number(limit) : 50;
3776
+ const lim = Math.max(1, Math.min(2000, Math.trunc(limRaw)));
3777
+ let q = 'SELECT Id, Name, Status, LastModifiedDate FROM ApexClass';
3778
+ if (name_filter != null && String(name_filter).trim() !== '') {
3779
+ q += ` WHERE Name LIKE '%${escapeSoqlString(String(name_filter).trim())}%'`;
3780
+ }
3781
+ q += ` ORDER BY LastModifiedDate DESC LIMIT ${lim}`;
3782
+ const data = await sfRequest('GET', '/query', 'tooling', { q });
3783
+ return text(data);
3784
+ },
3785
+ },
3786
+ ];
3787
+ }
3788
+
3789
+ function uiApiCommaQuery(value) {
3790
+ if (value == null) return undefined;
3791
+ if (Array.isArray(value)) return value.map((x) => String(x).trim()).filter(Boolean).join(',');
3792
+ return String(value).trim();
3793
+ }
3794
+
3795
+ function uiSmartTools() {
3796
+ return [
3797
+ {
3798
+ name: 'get_record_ui',
3799
+ description:
3800
+ 'Returns record data formatted for UI: labels, formatted values, and layout sections. Prefer over raw REST when showing records to users.',
3801
+ inputSchema: {
3802
+ type: 'object',
3803
+ properties: {
3804
+ record_id: {
3805
+ type: 'string',
3806
+ description: 'Salesforce record Id (or comma-separated Ids per UI API).',
3807
+ },
3808
+ layout_types: {
3809
+ description:
3810
+ 'Optional. Layout types: Compact, Full. Comma-separated string or array of strings.',
3811
+ oneOf: [
3812
+ { type: 'string' },
3813
+ { type: 'array', items: { type: 'string' } },
3814
+ ],
3815
+ },
3816
+ modes: {
3817
+ description: 'Optional. Create, Edit, View. Comma-separated string or array of strings.',
3818
+ oneOf: [
3819
+ { type: 'string' },
3820
+ { type: 'array', items: { type: 'string' } },
3821
+ ],
3822
+ },
3823
+ },
3824
+ required: ['record_id'],
3825
+ },
3826
+ handler: async ({ record_id, layout_types, modes }) => {
3827
+ const q = {
3828
+ RECORD_IDS: record_id,
3829
+ };
3830
+ const lt = uiApiCommaQuery(layout_types);
3831
+ const md = uiApiCommaQuery(modes);
3832
+ if (lt) q.layoutTypes = lt;
3833
+ if (md) q.modes = md;
3834
+ const data = await sfRequest('GET', '/record-ui/{RECORD_IDS}', 'ui-api', q);
3835
+ return text(data);
3836
+ },
3837
+ },
3838
+ {
3839
+ name: 'get_object_info',
3840
+ description:
3841
+ 'Returns UI-friendly metadata for any Salesforce object including field labels, data types, required fields, and default record type information. Essential before building forms, validating data, or displaying object information to users.',
3842
+ inputSchema: {
3843
+ type: 'object',
3844
+ properties: {
3845
+ object_api_name: { type: 'string', description: 'sObject API name (e.g. Account, CustomObject__c).' },
3846
+ },
3847
+ required: ['object_api_name'],
3848
+ },
3849
+ handler: async ({ object_api_name }) => {
3850
+ const data = await sfRequest(
3851
+ 'GET',
3852
+ '/object-info/{SOBJECT_API_NAME}',
3853
+ 'ui-api',
3854
+ { SOBJECT_API_NAME: object_api_name }
3855
+ );
3856
+ return text(data);
3857
+ },
3858
+ },
3859
+ {
3860
+ name: 'get_picklist_values_ui',
3861
+ description:
3862
+ 'Returns all picklist field values for a specified object with proper labels and valid options per record type in UI-ready format. Use this before creating forms or validating picklist selections to ensure data integrity and proper user experience.',
3863
+ inputSchema: {
3864
+ type: 'object',
3865
+ properties: {
3866
+ object_api_name: { type: 'string' },
3867
+ record_type_id: {
3868
+ type: 'string',
3869
+ description: 'Optional; defaults to defaultRecordTypeId from object-info when omitted.',
3870
+ },
3871
+ },
3872
+ required: ['object_api_name'],
3873
+ },
3874
+ handler: async ({ object_api_name, record_type_id }) => {
3875
+ let rtid =
3876
+ record_type_id != null && String(record_type_id).trim() !== ''
3877
+ ? String(record_type_id).trim()
3878
+ : null;
3879
+ if (!rtid) {
3880
+ const info = await sfRequest(
3881
+ 'GET',
3882
+ '/object-info/{SOBJECT_API_NAME}',
3883
+ 'ui-api',
3884
+ { SOBJECT_API_NAME: object_api_name }
3885
+ );
3886
+ rtid = info?.defaultRecordTypeId || null;
3887
+ if (!rtid && Array.isArray(info?.recordTypeInfos) && info.recordTypeInfos.length) {
3888
+ rtid = info.recordTypeInfos[0].recordTypeId || info.recordTypeInfos[0].id;
3889
+ }
3890
+ }
3891
+ if (!rtid) {
3892
+ throw new Error(
3893
+ 'record_type_id is required for this org/object (could not resolve defaultRecordTypeId).'
3894
+ );
3895
+ }
3896
+ const data = await sfRequest(
3897
+ 'GET',
3898
+ '/object-info/{SOBJECT_API_NAME}/picklist-values/{RECORD_TYPE_ID}',
3899
+ 'ui-api',
3900
+ { SOBJECT_API_NAME: object_api_name, RECORD_TYPE_ID: rtid }
3901
+ );
3902
+ return text(data);
3903
+ },
3904
+ },
3905
+ {
3906
+ name: 'get_layout',
3907
+ description:
3908
+ 'Returns the page layout configuration for a Salesforce object, including which fields appear in which sections and their display order. Use this to understand how records are structured and displayed to users, rather than just getting field metadata.',
3909
+ inputSchema: {
3910
+ type: 'object',
3911
+ properties: {
3912
+ object_api_name: { type: 'string' },
3913
+ layout_type: {
3914
+ type: 'string',
3915
+ description: 'Full or Compact (maps to layoutTypes query param).',
3916
+ enum: ['Full', 'Compact'],
3917
+ },
3918
+ record_type_id: { type: 'string', description: 'Record type Id for the layout.' },
3919
+ },
3920
+ required: ['object_api_name', 'layout_type', 'record_type_id'],
3921
+ },
3922
+ handler: async ({ object_api_name, layout_type, record_type_id }) => {
3923
+ const data = await sfRequest('GET', '/layout/{SOBJECT_API_NAME}', 'ui-api', {
3924
+ SOBJECT_API_NAME: object_api_name,
3925
+ layoutTypes: String(layout_type).trim(),
3926
+ recordTypeId: String(record_type_id).trim(),
3927
+ modes: 'View',
3928
+ });
3929
+ return text(data);
3930
+ },
3931
+ },
3932
+ ];
3933
+ }
3934
+
3935
+ function einsteinSmartTools() {
3936
+ return [
3937
+ {
3938
+ name: 'analyze_sentiment',
3939
+ description:
3940
+ 'Scores text sentiment (positive, negative, neutral) via Einstein Language (multipart sentiment API). Set SALESFORCE_EINSTEIN_ACCESS_TOKEN or EINSTEIN_ACCESS_TOKEN when needed; the org session token may be rejected by api.einstein.ai. Default model is CommunitySentiment.',
3941
+ inputSchema: {
3942
+ type: 'object',
3943
+ properties: {
3944
+ text: {
3945
+ description: 'Text to analyze, or an array of strings for batch calls.',
3946
+ oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
3947
+ },
3948
+ model_id: {
3949
+ type: 'string',
3950
+ description: 'Einstein Language model id. Omit to use CommunitySentiment.',
3951
+ },
3952
+ },
3953
+ required: ['text'],
3954
+ },
3955
+ handler: async (p) => {
3956
+ const modelId =
3957
+ p.model_id != null && String(p.model_id).trim() !== '' ? String(p.model_id).trim() : 'CommunitySentiment';
3958
+ const raw = p.text;
3959
+ const texts = Array.isArray(raw) ? raw.map((t) => String(t)) : [String(raw)];
3960
+ if (!texts.length || texts.every((t) => !t.trim())) throw new Error('text must be non-empty');
3961
+ const results = [];
3962
+ for (const doc of texts) {
3963
+ const row = await einsteinPlatformPostMultipart('/language/sentiment', (f) => {
3964
+ f.append('modelId', modelId);
3965
+ f.append('document', doc);
3966
+ });
3967
+ results.push(row);
3968
+ }
3969
+ return text(results.length === 1 ? results[0] : { results });
3970
+ },
3971
+ },
3972
+ {
3973
+ name: 'classify_intent',
3974
+ description:
3975
+ 'Classifies customer intent from text input using trained Einstein Language models to automatically route messages, emails, or cases to appropriate teams or processes. Requires a pre-trained Einstein Language model and Einstein Platform authentication.',
3976
+ inputSchema: {
3977
+ type: 'object',
3978
+ properties: {
3979
+ text: { type: 'string' },
3980
+ model_id: { type: 'string', description: 'Your trained intent model id.' },
3981
+ },
3982
+ required: ['text', 'model_id'],
3983
+ },
3984
+ handler: async ({ text, model_id }) => {
3985
+ const mid = String(model_id).trim();
3986
+ if (!mid) throw new Error('model_id is required');
3987
+ const data = await einsteinPlatformPostMultipart('/language/intent', (f) => {
3988
+ f.append('modelId', mid);
3989
+ f.append('document', String(text));
3990
+ });
3991
+ return text(data);
3992
+ },
3993
+ },
3994
+ {
3995
+ name: 'predict_image',
3996
+ description:
3997
+ 'Classifies images using trained Einstein Vision models, returning prediction labels with confidence scores for visual content analysis. Accepts either image_url or image_base64 parameters and requires Einstein Platform authentication.',
3998
+ inputSchema: {
3999
+ type: 'object',
4000
+ properties: {
4001
+ image_url: { type: 'string', description: 'Public URL of the image.' },
4002
+ image_base64: { type: 'string', description: 'Image bytes as base64.' },
4003
+ model_id: { type: 'string', description: 'Einstein Vision model id.' },
4004
+ },
4005
+ required: ['model_id'],
4006
+ },
4007
+ handler: async ({ image_url, image_base64, model_id }) => {
4008
+ const mid = String(model_id).trim();
4009
+ if (!mid) throw new Error('model_id is required');
4010
+ const url = image_url != null && String(image_url).trim() !== '' ? String(image_url).trim() : null;
4011
+ const b64 = image_base64 != null && String(image_base64).trim() !== '' ? String(image_base64).trim() : null;
4012
+ if (!url && !b64) throw new Error('Provide image_url or image_base64');
4013
+ const data = await einsteinPlatformPostMultipart('/vision/predict', (f) => {
4014
+ f.append('modelId', mid);
4015
+ if (url) f.append('sampleLocation', url);
4016
+ else f.append('sampleBase64Content', b64);
4017
+ });
4018
+ return text(data);
4019
+ },
4020
+ },
4021
+ ];
4022
+ }
4023
+
4024
+ function toPlatformEventApiName(event_name) {
4025
+ const s = String(event_name ?? '').trim();
4026
+ if (!s) throw new Error('event_name is required');
4027
+ if (s.endsWith('__e')) return s;
4028
+ return `${s}__e`;
4029
+ }
4030
+
4031
+ function eventPlatformSmartTools() {
4032
+ return [
4033
+ {
4034
+ name: 'publish_platform_event',
4035
+ description:
4036
+ 'Publishes custom Platform Events to Salesforce\'s event bus to trigger real-time integrations, notifications, or automated workflows that subscribe to these events. Use this to initiate event-driven processes across your Salesforce ecosystem.',
4037
+ inputSchema: {
4038
+ type: 'object',
4039
+ properties: {
4040
+ event_name: {
4041
+ type: 'string',
4042
+ description: 'Platform event API name (with or without __e suffix).',
4043
+ },
4044
+ event_data: {
4045
+ type: 'object',
4046
+ additionalProperties: true,
4047
+ description: 'Field API names → values for the event payload.',
4048
+ },
4049
+ },
4050
+ required: ['event_name'],
4051
+ },
4052
+ handler: async ({ event_name, event_data }) => {
4053
+ const apiName = toPlatformEventApiName(event_name);
4054
+ const body = event_data != null && typeof event_data === 'object' ? event_data : {};
4055
+ const data = await sfRequest('POST', '/sobjects/{EventApiName}/', 'versioned', {
4056
+ EventApiName: apiName,
4057
+ _rawBody: body,
4058
+ });
4059
+ return text(data);
4060
+ },
4061
+ },
4062
+ {
4063
+ name: 'list_platform_events',
4064
+ description:
4065
+ 'Lists all available Platform Event types in your org by querying EntityDefinition records with __e suffix. Use this to discover what events you can publish before using publish_platform_event.',
4066
+ inputSchema: { type: 'object', properties: {}, required: [] },
4067
+ handler: async () => {
4068
+ const q =
4069
+ "SELECT QualifiedApiName, Label, DeveloperName, DurableId FROM EntityDefinition WHERE QualifiedApiName LIKE '%__e' ORDER BY QualifiedApiName LIMIT 500";
4070
+ const data = await sfRequest('GET', '/query', 'tooling', { q });
4071
+ return text(data);
4072
+ },
4073
+ },
4074
+ {
4075
+ name: 'create_streaming_topic',
4076
+ description:
4077
+ 'Creates a PushTopic for the Streaming API to monitor real-time changes to Salesforce records based on SOQL criteria. Use this to set up live data synchronization with external systems that need immediate updates.',
4078
+ inputSchema: {
4079
+ type: 'object',
4080
+ properties: {
4081
+ name: { type: 'string', description: 'Unique PushTopic name.' },
4082
+ query: { type: 'string', description: 'SOQL query (Id, fields must be valid for streaming).' },
4083
+ notify_for_fields: {
4084
+ type: 'string',
4085
+ description: 'PushTopic NotifyForFields: Referenced (default), Select, Where, or All.',
4086
+ enum: ['Referenced', 'Select', 'Where', 'All'],
4087
+ },
4088
+ },
4089
+ required: ['name', 'query'],
4090
+ },
4091
+ handler: async ({ name, query, notify_for_fields }) => {
4092
+ const verRaw = process.env.SALESFORCE_API_VERSION || 'v59.0';
4093
+ const apiNum = Number.parseFloat(String(verRaw).replace(/^v/i, '')) || 59;
4094
+ const notify = notify_for_fields != null && String(notify_for_fields).trim() !== ''
4095
+ ? String(notify_for_fields).trim()
4096
+ : 'Referenced';
4097
+ const data = await sfRequest('POST', '/sobjects/PushTopic', 'versioned', {
4098
+ _rawBody: {
4099
+ Name: String(name).trim(),
4100
+ Query: String(query),
4101
+ NotifyForFields: notify,
4102
+ ApiVersion: apiNum,
4103
+ },
4104
+ });
4105
+ return text(data);
4106
+ },
4107
+ },
4108
+ ];
4109
+ }
4110
+
4111
+ const GQL_ACCOUNTS_WITH_CONTACTS = `query accountsWithContacts {
4112
+ uiapi {
4113
+ query {
4114
+ Account {
4115
+ edges {
4116
+ node {
4117
+ Id
4118
+ Name { value }
4119
+ Contacts {
4120
+ edges {
4121
+ node {
4122
+ Id
4123
+ Name { value }
4124
+ Email { value }
4125
+ }
4126
+ }
4127
+ }
4128
+ }
4129
+ }
4130
+ }
4131
+ }
4132
+ }
4133
+ }`;
4134
+
4135
+ function graphqlSmartTools() {
4136
+ return [
4137
+ {
4138
+ name: 'graphql_query',
4139
+ description:
4140
+ 'Executes GraphQL queries against Salesforce data for more efficient retrieval of complex, nested relationships in a single request. Use this instead of multiple REST API calls when you need related data from multiple objects simultaneously.',
4141
+ inputSchema: {
4142
+ type: 'object',
4143
+ properties: {
4144
+ query: { type: 'string', description: 'GraphQL query string.' },
4145
+ variables: {
4146
+ type: 'object',
4147
+ description: 'Optional GraphQL variables object.',
4148
+ additionalProperties: true,
4149
+ },
4150
+ },
4151
+ required: ['query'],
4152
+ },
4153
+ handler: async ({ query, variables }) => {
4154
+ const body = { query };
4155
+ if (
4156
+ variables != null &&
4157
+ typeof variables === 'object' &&
4158
+ !Array.isArray(variables) &&
4159
+ Object.keys(variables).length > 0
4160
+ ) {
4161
+ body.variables = variables;
4162
+ }
4163
+ const data = await sfRequest('POST', '/', 'graphql', { _rawBody: body });
4164
+ return text(data);
4165
+ },
4166
+ },
4167
+ {
4168
+ name: 'graphql_query_accounts_with_contacts',
4169
+ description:
4170
+ 'Retrieves accounts with their related contacts in one efficient GraphQL request, returning only specified fields to minimize data transfer. Use this instead of separate SOQL queries when you need account-contact hierarchical data.',
4171
+ inputSchema: { type: 'object', properties: {}, required: [] },
4172
+ handler: async () => {
4173
+ const data = await sfRequest('POST', '/', 'graphql', {
4174
+ _rawBody: { query: GQL_ACCOUNTS_WITH_CONTACTS },
4175
+ });
4176
+ return text(data);
4177
+ },
4178
+ },
4179
+ ];
4180
+ }
4181
+
4182
+ async function resolvePricebook2Id(pricebook_id) {
4183
+ if (pricebook_id != null && String(pricebook_id).trim() !== '') return String(pricebook_id).trim();
4184
+ const q =
4185
+ "SELECT Id FROM Pricebook2 WHERE Name = 'Standard Price Book' AND IsActive = true LIMIT 1";
4186
+ const d = await sfRequest('GET', '/query/', 'versioned', { q });
4187
+ return d.records?.[0]?.Id || null;
4188
+ }
4189
+
4190
+ function subscriptionMgmtSmartTools() {
4191
+ return [
4192
+ {
4193
+ name: 'get_product_catalog',
4194
+ description:
4195
+ 'Retrieves products from a Salesforce price book with names, product codes, and pricing information needed for creating opportunities or orders. Defaults to Standard Price Book if no pricebook_id is specified.',
4196
+ inputSchema: {
4197
+ type: 'object',
4198
+ properties: {
4199
+ pricebook_id: { type: 'string', description: 'Optional Pricebook2 Id (defaults to Standard Price Book).' },
4200
+ active_only: {
4201
+ type: 'boolean',
4202
+ description: 'If true (default), only active PricebookEntry and Product2 rows.',
4203
+ default: true,
4204
+ },
4205
+ },
4206
+ required: [],
4207
+ },
4208
+ handler: async ({ pricebook_id, active_only }) => {
4209
+ const pbId = await resolvePricebook2Id(pricebook_id);
4210
+ if (!pbId) throw new Error('Could not resolve price book (set pricebook_id or ensure Standard Price Book exists).');
4211
+ const active = active_only !== false;
4212
+ let q = `SELECT Id, Name, Product2Id, Product2.Name, Product2.ProductCode, Product2.IsActive, UnitPrice, IsActive, UseStandardPrice FROM PricebookEntry WHERE Pricebook2Id='${escapeSoqlString(pbId)}'`;
4213
+ if (active) q += ' AND IsActive = true AND Product2.IsActive = true';
4214
+ q += ' ORDER BY Product2.Name LIMIT 2000';
4215
+ const data = await sfRequest('GET', '/query/', 'versioned', { q });
4216
+ return text({ pricebook_id: pbId, ...data });
4217
+ },
4218
+ },
4219
+ {
4220
+ name: 'create_order_from_opportunity',
4221
+ description:
4222
+ 'Creates an Order from an Opportunity and copies opportunity line items to order products. Use when lines have valid PricebookEntryId for the order price book; satisfy required Order fields (e.g. BillToContactId) via org automation or follow-up updates.',
4223
+ inputSchema: {
4224
+ type: 'object',
4225
+ properties: {
4226
+ opportunity_id: { type: 'string' },
4227
+ pricebook_id: { type: 'string', description: "Used if the opportunity has no price book; else opportunity's price book is used." },
4228
+ effective_date: {
4229
+ type: 'string',
4230
+ description: 'Order effective date (YYYY-MM-DD).',
4231
+ },
4232
+ },
4233
+ required: ['opportunity_id', 'pricebook_id', 'effective_date'],
4234
+ },
4235
+ handler: async ({ opportunity_id, pricebook_id, effective_date }) => {
4236
+ const oid = escapeSoqlString(opportunity_id);
4237
+ const oppRes = await sfRequest('GET', '/query/', 'versioned', {
4238
+ q: `SELECT Id, AccountId, Pricebook2Id, CurrencyIsoCode FROM Opportunity WHERE Id='${oid}' LIMIT 1`,
4239
+ });
4240
+ const opp = oppRes.records?.[0];
4241
+ if (!opp) throw new Error('Opportunity not found');
4242
+ const pbFromParam = pricebook_id != null && String(pricebook_id).trim() !== '' ? String(pricebook_id).trim() : null;
4243
+ const pbId = pbFromParam || opp.Pricebook2Id;
4244
+ if (!pbId) throw new Error('No price book on opportunity and pricebook_id not provided');
4245
+ const eff = String(effective_date).trim().slice(0, 10);
4246
+
4247
+ const oliRes = await sfRequest('GET', '/query/', 'versioned', {
4248
+ q: `SELECT Id, Product2Id, Quantity, UnitPrice, PricebookEntryId, Description FROM OpportunityLineItem WHERE OpportunityId='${oid}'`,
4249
+ });
4250
+ const lines = oliRes.records || [];
4251
+ if (!lines.length) throw new Error('No opportunity line items to convert');
4252
+
4253
+ const orderBody = {
4254
+ AccountId: opp.AccountId,
4255
+ OpportunityId: opp.Id,
4256
+ Pricebook2Id: pbId,
4257
+ EffectiveDate: eff,
4258
+ Status: 'Draft',
4259
+ };
4260
+ if (opp.CurrencyIsoCode) orderBody.CurrencyIsoCode = opp.CurrencyIsoCode;
4261
+
4262
+ const order = await sfRequest('POST', '/sobjects/Order', 'versioned', { _rawBody: orderBody });
4263
+ const orderId = order.id;
4264
+ if (!orderId) throw new Error('Order create failed (check required Order fields for your org)');
4265
+
4266
+ const createdItems = [];
4267
+ for (const line of lines) {
4268
+ if (!line.PricebookEntryId) {
4269
+ throw new Error(`Opportunity line ${line.Id} has no PricebookEntryId; refresh lines with the order price book`);
4270
+ }
4271
+ const oiBody = {
4272
+ OrderId: orderId,
4273
+ PricebookEntryId: line.PricebookEntryId,
4274
+ Quantity: line.Quantity,
4275
+ UnitPrice: line.UnitPrice,
4276
+ };
4277
+ if (line.Description) oiBody.Description = line.Description;
4278
+ const oi = await sfRequest('POST', '/sobjects/OrderItem', 'versioned', { _rawBody: oiBody });
4279
+ createdItems.push(oi);
4280
+ }
4281
+
4282
+ return text({ order, orderItems: createdItems });
4283
+ },
4284
+ },
4285
+ {
4286
+ name: 'get_subscription_status',
4287
+ description:
4288
+ 'Lists recent Orders for an account with nested OrderItems (product, quantity, prices, dates). Useful when subscriptions or renewals are modeled on Order; patterns differ by org and edition.',
4289
+ inputSchema: {
4290
+ type: 'object',
4291
+ properties: {
4292
+ account_id: { type: 'string', description: 'Salesforce Account Id.' },
4293
+ },
4294
+ required: ['account_id'],
4295
+ },
4296
+ handler: async ({ account_id }) => {
4297
+ const aid = escapeSoqlString(account_id);
4298
+ const q = `SELECT Id, OrderNumber, Status, EffectiveDate, TotalAmount,
4299
+ (SELECT Id, Product2Id, Product2.Name, Quantity, UnitPrice, ListPrice, ServiceDate FROM OrderItems ORDER BY CreatedDate)
4300
+ FROM Order WHERE AccountId='${aid}' ORDER BY EffectiveDate DESC LIMIT 200`;
4301
+ const data = await sfRequest('GET', '/query/', 'versioned', { q });
4302
+ return text(data);
4303
+ },
4304
+ },
4305
+ ];
4306
+ }
4307
+
4308
+ function enrichSchemaForWrite(schema, method) {
4309
+ const s = structuredClone(schema);
4310
+ s.properties = s.properties || {};
4311
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
4312
+ s.properties._rawBody = {
4313
+ type: 'object',
4314
+ description:
4315
+ 'Optional: full JSON body. When set, replaces inferred body fields from other arguments.',
4316
+ additionalProperties: true,
4317
+ };
4318
+ }
4319
+ return s;
4320
+ }
4321
+
4322
+ function shouldSkipManifestEndpoint(ep) {
4323
+ if (ep.method === 'GET' && ep.path === '/query/') return true;
4324
+ if (ep.method === 'GET' && ep.path === '/search') return true;
4325
+ return false;
4326
+ }
4327
+
4328
+ function methodRank(m) {
4329
+ return (
4330
+ { GET: 0, POST: 1, PATCH: 2, PUT: 3, DELETE: 4 }[m] ?? 99
4331
+ );
4332
+ }
4333
+
4334
+ function generatedToolsFromManifest() {
4335
+ const out = [];
4336
+ for (const ep of manifest.endpoints) {
4337
+ if (shouldSkipManifestEndpoint(ep)) continue;
4338
+ out.push({
4339
+ name: ep.name,
4340
+ description: ep.description,
4341
+ method: ep.method,
4342
+ path: ep.path,
4343
+ urlKind: ep.urlKind,
4344
+ inputSchema: enrichSchemaForWrite(ep.inputSchema, ep.method),
4345
+ handler: async (params) => {
4346
+ const data = await sfRequest(ep.method, ep.path, ep.urlKind, params);
4347
+ return text(data);
4348
+ },
4349
+ });
4350
+ }
4351
+ out.sort((a, b) => {
4352
+ const ea = manifest.endpoints.find((e) => e.name === a.name);
4353
+ const eb = manifest.endpoints.find((e) => e.name === b.name);
4354
+ const ra = methodRank(ea?.method);
4355
+ const rb = methodRank(eb?.method);
4356
+ if (ra !== rb) return ra - rb;
4357
+ return a.name.localeCompare(b.name);
4358
+ });
4359
+ return out;
4360
+ }
4361
+
4362
+ const mult = multipliedTools();
4363
+ const gen = generatedToolsFromManifest();
4364
+ const bulkWrappers = bulkApiMultipliedTools();
4365
+ const bulkGen = bulkV2GeneratedTools();
4366
+ const bulkV1Gen = bulkV1GeneratedTools();
4367
+ const compositeTools = compositeSmartTools();
4368
+ const metadataSmart = metadataRestSmartTools();
4369
+ const metadataSoap = metadataSoapToolsFromPostman();
4370
+ const connectGen = connectGeneratedToolsFromPostman();
4371
+ const connectSmart = connectSmartTools();
4372
+ const toolingGen = toolingGeneratedToolsFromPostman();
4373
+ const toolingSmart = toolingSmartTools();
4374
+ const uiGen = uiGeneratedToolsFromPostman();
4375
+ const uiSmart = uiSmartTools();
4376
+ const einsteinPsGen = einsteinPsGeneratedToolsFromPostman();
4377
+ const einsteinSmart = einsteinSmartTools();
4378
+ const evtPfGen = eventPlatformGeneratedToolsFromPostman();
4379
+ const evtPfSmart = eventPlatformSmartTools();
4380
+ const gqlGen = graphqlGeneratedToolsFromPostman();
4381
+ const gqlSmart = graphqlSmartTools();
4382
+ const subMgmtGen = subMgmtGeneratedToolsFromPostman();
4383
+ const subMgmtSmart = subscriptionMgmtSmartTools();
4384
+ const industriesGen = industriesGeneratedToolsFromPostman();
4385
+
4386
+ const seen = new Set(mult.map((t) => t.name));
4387
+ for (const t of gen) {
4388
+ if (seen.has(t.name)) {
4389
+ t.name = `${t.name}_rest`;
4390
+ }
4391
+ seen.add(t.name);
4392
+ }
4393
+ for (const t of bulkWrappers) {
4394
+ if (seen.has(t.name)) {
4395
+ t.name = `${t.name}_wrap`;
4396
+ }
4397
+ seen.add(t.name);
4398
+ }
4399
+ for (const t of bulkGen) {
4400
+ if (seen.has(t.name)) {
4401
+ t.name = `${t.name}_bulk`;
4402
+ }
4403
+ seen.add(t.name);
4404
+ }
4405
+ for (const t of bulkV1Gen) {
4406
+ if (seen.has(t.name)) {
4407
+ t.name = `${t.name}_bv1`;
4408
+ }
4409
+ seen.add(t.name);
4410
+ }
4411
+ for (const t of compositeTools) {
4412
+ if (seen.has(t.name)) {
4413
+ t.name = `${t.name}_comp`;
4414
+ }
4415
+ seen.add(t.name);
4416
+ }
4417
+ for (const t of metadataSmart) {
4418
+ if (seen.has(t.name)) {
4419
+ t.name = `${t.name}_meta`;
4420
+ }
4421
+ seen.add(t.name);
4422
+ }
4423
+ for (const t of metadataSoap) {
4424
+ if (seen.has(t.name)) {
4425
+ t.name = `${t.name}_soap`;
4426
+ }
4427
+ seen.add(t.name);
4428
+ }
4429
+ for (const t of connectGen) {
4430
+ if (seen.has(t.name)) {
4431
+ t.name = `${t.name}_conn`;
4432
+ }
4433
+ seen.add(t.name);
4434
+ }
4435
+ for (const t of connectSmart) {
4436
+ if (seen.has(t.name)) {
4437
+ t.name = `${t.name}_conn`;
4438
+ }
4439
+ seen.add(t.name);
4440
+ }
4441
+ for (const t of toolingGen) {
4442
+ if (seen.has(t.name)) {
4443
+ t.name = `${t.name}_tl`;
4444
+ }
4445
+ seen.add(t.name);
4446
+ }
4447
+ for (const t of toolingSmart) {
4448
+ if (seen.has(t.name)) {
4449
+ t.name = `${t.name}_tl`;
4450
+ }
4451
+ seen.add(t.name);
4452
+ }
4453
+ for (const t of uiGen) {
4454
+ if (seen.has(t.name)) {
4455
+ t.name = `${t.name}_uia`;
4456
+ }
4457
+ seen.add(t.name);
4458
+ }
4459
+ for (const t of uiSmart) {
4460
+ if (seen.has(t.name)) {
4461
+ t.name = `${t.name}_uia`;
4462
+ }
4463
+ seen.add(t.name);
4464
+ }
4465
+ for (const t of einsteinPsGen) {
4466
+ if (seen.has(t.name)) {
4467
+ t.name = `${t.name}_eps`;
4468
+ }
4469
+ seen.add(t.name);
4470
+ }
4471
+ for (const t of einsteinSmart) {
4472
+ if (seen.has(t.name)) {
4473
+ t.name = `${t.name}_eps`;
4474
+ }
4475
+ seen.add(t.name);
4476
+ }
4477
+ for (const t of evtPfGen) {
4478
+ if (seen.has(t.name)) {
4479
+ t.name = `${t.name}_evp`;
4480
+ }
4481
+ seen.add(t.name);
4482
+ }
4483
+ for (const t of evtPfSmart) {
4484
+ if (seen.has(t.name)) {
4485
+ t.name = `${t.name}_evp`;
4486
+ }
4487
+ seen.add(t.name);
4488
+ }
4489
+ for (const t of gqlGen) {
4490
+ if (seen.has(t.name)) {
4491
+ t.name = `${t.name}_gql`;
4492
+ }
4493
+ seen.add(t.name);
4494
+ }
4495
+ for (const t of gqlSmart) {
4496
+ if (seen.has(t.name)) {
4497
+ t.name = `${t.name}_gql`;
4498
+ }
4499
+ seen.add(t.name);
4500
+ }
4501
+ for (const t of subMgmtGen) {
4502
+ if (seen.has(t.name)) {
4503
+ t.name = `${t.name}_subm`;
4504
+ }
4505
+ seen.add(t.name);
4506
+ }
4507
+ for (const t of subMgmtSmart) {
4508
+ if (seen.has(t.name)) {
4509
+ t.name = `${t.name}_subm`;
4510
+ }
4511
+ seen.add(t.name);
4512
+ }
4513
+ for (const t of industriesGen) {
4514
+ if (seen.has(t.name)) {
4515
+ t.name = `${t.name}_ind`;
4516
+ }
4517
+ seen.add(t.name);
4518
+ }
4519
+
4520
+ export const tools = [
4521
+ ...mult,
4522
+ ...gen,
4523
+ ...bulkWrappers,
4524
+ ...bulkGen,
4525
+ ...bulkV1Gen,
4526
+ ...compositeTools,
4527
+ ...metadataSmart,
4528
+ ...metadataSoap,
4529
+ ...connectGen,
4530
+ ...connectSmart,
4531
+ ...toolingGen,
4532
+ ...toolingSmart,
4533
+ ...uiGen,
4534
+ ...uiSmart,
4535
+ ...einsteinPsGen,
4536
+ ...einsteinSmart,
4537
+ ...evtPfGen,
4538
+ ...evtPfSmart,
4539
+ ...gqlGen,
4540
+ ...gqlSmart,
4541
+ ...subMgmtGen,
4542
+ ...subMgmtSmart,
4543
+ ...industriesGen,
4544
+ ];