@alfe.ai/salesforce-mcp 0.0.17 → 0.0.19

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 (3) hide show
  1. package/README.md +11 -0
  2. package/dist/server.js +362 -82
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -10,6 +10,17 @@ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: buil
10
10
  npm install @alfe.ai/salesforce-mcp
11
11
  ```
12
12
 
13
+ The server is normally launched by the Alfe Salesforce integration. Call
14
+ `salesforce_list_orgs` first and pass its exact `orgId` to every data tool.
15
+ Queries and reads are bounded, write payloads accept JSON-only Salesforce field
16
+ maps, and permanent deletion requires `confirmRecordId` to exactly match the
17
+ record being deleted.
18
+
19
+ Credentials are fetched through Alfe Connect. Access tokens are sent only to a
20
+ canonical HTTPS `*.salesforce.com` or `*.salesforce.mil` instance origin; REST
21
+ requests use a 30-second deadline, disable redirects, and cap responses at
22
+ 5 MiB.
23
+
13
24
  ## Links
14
25
 
15
26
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -1,95 +1,306 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
5
5
  import { resolveConfig } from "@alfe.ai/config";
6
6
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
7
+ import { z } from "zod";
8
+ //#region src/validation.ts
9
+ const MAX_QUERY_CHARS = 1e5;
10
+ const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
11
+ const MAX_JSON_DEPTH = 8;
12
+ const MAX_JSON_NODES = 1e4;
13
+ const MAX_JSON_ARRAY_ITEMS = 1e3;
14
+ const MAX_JSON_STRING_CHARS = 1e5;
15
+ const SOBJECT_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,79}$/u;
16
+ const FIELD_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,79}$/u;
17
+ const RECORD_ID_PATTERN = /^(?:[A-Za-z0-9]{15}|[A-Za-z0-9]{18})$/u;
18
+ const UNSAFE_KEYS = new Set([
19
+ "__proto__",
20
+ "constructor",
21
+ "prototype"
22
+ ]);
23
+ function validateOrgId(value) {
24
+ const normalized = boundedTrimmedString("orgId", value, 18);
25
+ if (!RECORD_ID_PATTERN.test(normalized)) throw new Error("orgId must contain 15 or 18 alphanumeric characters");
26
+ return normalized;
27
+ }
28
+ function validateSObject(value) {
29
+ const normalized = boundedTrimmedString("sObject", value, 80);
30
+ if (!SOBJECT_PATTERN.test(normalized)) throw new Error("sObject must be a Salesforce API object name");
31
+ return normalized;
32
+ }
33
+ function validateRecordId(value) {
34
+ const normalized = boundedTrimmedString("record id", value, 18);
35
+ if (!RECORD_ID_PATTERN.test(normalized)) throw new Error("record id must contain 15 or 18 alphanumeric characters");
36
+ return normalized;
37
+ }
38
+ function validateQuery(label, value) {
39
+ return boundedTrimmedString(label, value, MAX_QUERY_CHARS);
40
+ }
41
+ function validateFieldNames(fields) {
42
+ if (fields === void 0) return void 0;
43
+ if (!Array.isArray(fields) || fields.length < 1 || fields.length > 200) throw new Error(`fields must contain 1 to ${String(200)} API names`);
44
+ const normalized = fields.map((field) => validateFieldName(field));
45
+ if (new Set(normalized).size !== normalized.length) throw new Error("fields must not contain duplicates");
46
+ return normalized;
47
+ }
48
+ function validateRecordFields(fields) {
49
+ if (!isRecord$1(fields)) throw new Error("record fields must be an object");
50
+ const entries = Object.entries(fields);
51
+ if (entries.length < 1 || entries.length > 200) throw new Error(`record fields must contain 1 to ${String(200)} entries`);
52
+ const budget = { nodes: 0 };
53
+ const result = Object.create(null);
54
+ for (const [key, value] of entries) result[validateFieldName(key)] = cloneJson(value, 0, budget);
55
+ const encoded = JSON.stringify(result);
56
+ if (Buffer.byteLength(encoded, "utf-8") > 1048576) throw new Error(`record fields exceed the ${String(MAX_REQUEST_BODY_BYTES)} byte request limit`);
57
+ return result;
58
+ }
59
+ function validateFieldName(value) {
60
+ const normalized = boundedTrimmedString("field name", value, 80);
61
+ if (UNSAFE_KEYS.has(normalized) || !FIELD_PATTERN.test(normalized)) throw new Error("field name must be a safe Salesforce API field name");
62
+ return normalized;
63
+ }
64
+ function boundedTrimmedString(label, value, maxLength) {
65
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
66
+ const normalized = value.trim();
67
+ if (normalized.length < 1 || normalized.length > maxLength) throw new Error(`${label} must contain 1 to ${String(maxLength)} characters`);
68
+ return normalized;
69
+ }
70
+ function cloneJson(value, depth, budget) {
71
+ budget.nodes += 1;
72
+ if (budget.nodes > MAX_JSON_NODES) throw new Error("record fields contain too many JSON values");
73
+ if (depth > MAX_JSON_DEPTH) throw new Error("record fields exceed the JSON depth limit");
74
+ if (value === null || typeof value === "boolean") return value;
75
+ if (typeof value === "number") {
76
+ if (!Number.isFinite(value)) throw new Error("record fields contain a non-finite number");
77
+ return value;
78
+ }
79
+ if (typeof value === "string") {
80
+ if (value.length > MAX_JSON_STRING_CHARS) throw new Error("record fields contain an oversized string");
81
+ return value;
82
+ }
83
+ if (Array.isArray(value)) {
84
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error("record fields contain an oversized array");
85
+ return value.map((item) => cloneJson(item, depth + 1, budget));
86
+ }
87
+ if (!isRecord$1(value)) throw new Error("record fields must contain JSON-only values");
88
+ const output = Object.create(null);
89
+ for (const [key, nested] of Object.entries(value)) {
90
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key)) throw new Error("record fields contain an unsafe nested key");
91
+ output[key] = cloneJson(nested, depth + 1, budget);
92
+ }
93
+ return output;
94
+ }
95
+ function isRecord$1(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+ //#endregion
7
99
  //#region src/salesforce-client.ts
100
+ /** Bounded direct Salesforce REST client with per-org refresh-and-retry. */
101
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
102
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
103
+ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
104
+ const MAX_ACCESS_TOKEN_LENGTH = 16384;
105
+ const API_VERSION_PATTERN = /^(?:[1-9]\d{0,2})\.0$/u;
8
106
  var SalesforceClient = class {
9
107
  accessToken;
10
- instanceUrl;
108
+ instanceOrigin;
11
109
  apiVersion;
12
110
  onRefresh;
111
+ fetchImpl;
112
+ requestTimeoutMs;
13
113
  constructor(config) {
14
- this.accessToken = config.accessToken;
15
- this.instanceUrl = config.instanceUrl.replace(/\/+$/, "");
16
- this.apiVersion = config.apiVersion;
114
+ this.accessToken = validateAccessToken(config.accessToken);
115
+ this.instanceOrigin = validateSalesforceInstanceUrl(config.instanceUrl);
116
+ this.apiVersion = validateSalesforceApiVersion(config.apiVersion);
117
+ if (typeof config.onRefresh !== "function") throw new TypeError("Salesforce refresh callback is required");
17
118
  this.onRefresh = config.onRefresh;
119
+ this.fetchImpl = config.fetchImpl ?? fetch;
120
+ this.requestTimeoutMs = validateTimeout(config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
18
121
  }
19
122
  updateToken(accessToken) {
20
- this.accessToken = accessToken;
123
+ this.accessToken = validateAccessToken(accessToken);
21
124
  }
22
- get baseUrl() {
23
- return `${this.instanceUrl}/services/data/v${this.apiVersion}`;
125
+ get basePath() {
126
+ return `/services/data/v${this.apiVersion}`;
24
127
  }
25
- /** Run a SOQL query. Returns the standard `{ totalSize, done, records }`. */
26
128
  async query(soql) {
27
- return this.request("GET", `${this.baseUrl}/query?q=${encodeURIComponent(soql)}`);
129
+ const url = new URL(`${this.basePath}/query`, this.instanceOrigin);
130
+ url.searchParams.set("q", validateQuery("SOQL", soql));
131
+ return this.request("GET", url);
28
132
  }
29
- /** Run a SOSL search. Returns the standard `{ searchRecords }`. */
30
133
  async search(sosl) {
31
- return this.request("GET", `${this.baseUrl}/search?q=${encodeURIComponent(sosl)}`);
134
+ const url = new URL(`${this.basePath}/search`, this.instanceOrigin);
135
+ url.searchParams.set("q", validateQuery("SOSL", sosl));
136
+ return this.request("GET", url);
32
137
  }
33
- /** Fetch one record by id, optionally projecting a subset of fields. */
34
138
  async getRecord(sobject, id, fields) {
35
- let url = `${this.baseUrl}/sobjects/${encodeURIComponent(sobject)}/${encodeURIComponent(id)}`;
36
- if (fields?.length) url += `?fields=${encodeURIComponent(fields.join(","))}`;
139
+ const objectName = encodeURIComponent(validateSObject(sobject));
140
+ const recordId = encodeURIComponent(validateRecordId(id));
141
+ const url = new URL(`${this.basePath}/sobjects/${objectName}/${recordId}`, this.instanceOrigin);
142
+ const selectedFields = validateFieldNames(fields);
143
+ if (selectedFields) url.searchParams.set("fields", selectedFields.join(","));
37
144
  return this.request("GET", url);
38
145
  }
39
146
  async createRecord(sobject, fields) {
40
- return this.request("POST", `${this.baseUrl}/sobjects/${encodeURIComponent(sobject)}`, fields);
147
+ const objectName = encodeURIComponent(validateSObject(sobject));
148
+ return this.request("POST", new URL(`${this.basePath}/sobjects/${objectName}`, this.instanceOrigin), validateRecordFields(fields));
41
149
  }
42
- /** Update is a PATCH that returns 204 No Content on success. */
43
150
  async updateRecord(sobject, id, fields) {
44
- return this.request("PATCH", `${this.baseUrl}/sobjects/${encodeURIComponent(sobject)}/${encodeURIComponent(id)}`, fields);
151
+ const objectName = encodeURIComponent(validateSObject(sobject));
152
+ const recordId = encodeURIComponent(validateRecordId(id));
153
+ await this.request("PATCH", new URL(`${this.basePath}/sobjects/${objectName}/${recordId}`, this.instanceOrigin), validateRecordFields(fields));
45
154
  }
46
- /** Delete returns 204 No Content on success. */
47
155
  async deleteRecord(sobject, id) {
48
- return this.request("DELETE", `${this.baseUrl}/sobjects/${encodeURIComponent(sobject)}/${encodeURIComponent(id)}`);
156
+ const objectName = encodeURIComponent(validateSObject(sobject));
157
+ const recordId = encodeURIComponent(validateRecordId(id));
158
+ await this.request("DELETE", new URL(`${this.basePath}/sobjects/${objectName}/${recordId}`, this.instanceOrigin));
49
159
  }
50
- /** Object metadata (fields, child relationships, picklist values, ...). */
51
160
  async describe(sobject) {
52
- return this.request("GET", `${this.baseUrl}/sobjects/${encodeURIComponent(sobject)}/describe`);
161
+ const objectName = encodeURIComponent(validateSObject(sobject));
162
+ return this.request("GET", new URL(`${this.basePath}/sobjects/${objectName}/describe`, this.instanceOrigin));
53
163
  }
54
- headers() {
164
+ headers(hasBody) {
55
165
  return {
56
166
  Authorization: `Bearer ${this.accessToken}`,
57
- "Content-Type": "application/json",
58
- Accept: "application/json"
167
+ Accept: "application/json",
168
+ ...hasBody ? { "Content-Type": "application/json" } : {}
59
169
  };
60
170
  }
61
171
  async request(method, url, body) {
172
+ const encodedBody = body === void 0 ? void 0 : encodeRequestBody(body);
62
173
  const doFetch = async () => {
63
- const init = {
64
- method,
65
- headers: this.headers()
66
- };
67
- if (body !== void 0) init.body = JSON.stringify(body);
68
- return fetch(url, init);
174
+ try {
175
+ return await this.fetchImpl(url, {
176
+ method,
177
+ headers: this.headers(encodedBody !== void 0),
178
+ body: encodedBody,
179
+ redirect: "error",
180
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
181
+ });
182
+ } catch (error) {
183
+ throw new Error(`Salesforce API ${method} ${url.pathname} request failed`, { cause: error });
184
+ }
69
185
  };
70
186
  let response = await doFetch();
71
187
  if (response.status === 401) {
72
- this.accessToken = (await this.onRefresh()).accessToken;
188
+ let refreshed;
189
+ try {
190
+ refreshed = await this.onRefresh();
191
+ } catch (error) {
192
+ throw new Error("Salesforce access-token refresh failed", { cause: error });
193
+ }
194
+ this.accessToken = validateAccessToken(refreshed.accessToken);
73
195
  response = await doFetch();
74
196
  }
75
197
  if (!response.ok) {
76
- const errorBody = await response.text();
77
- throw new Error(`Salesforce API ${method} ${url} failed (${String(response.status)}): ${errorBody}`);
198
+ const errorBody = await readResponseText(response, MAX_ERROR_RESPONSE_BYTES);
199
+ throw new Error(formatApiError(method, url.pathname, response.status, errorBody));
200
+ }
201
+ if (response.status === 204) return {};
202
+ const text = await readResponseText(response, MAX_RESPONSE_BYTES);
203
+ if (text.length === 0) return {};
204
+ try {
205
+ return JSON.parse(text);
206
+ } catch (error) {
207
+ throw new Error(`Salesforce API ${method} ${url.pathname} returned invalid JSON`, { cause: error });
78
208
  }
79
- const text = await response.text();
80
- if (!text) return {};
81
- return JSON.parse(text);
82
209
  }
83
210
  };
211
+ function validateSalesforceInstanceUrl(rawUrl) {
212
+ if (typeof rawUrl !== "string" || rawUrl.length < 1 || rawUrl.length > 2048) throw new TypeError("Salesforce instance URL is invalid");
213
+ let url;
214
+ try {
215
+ url = new URL(rawUrl);
216
+ } catch {
217
+ throw new TypeError("Salesforce instance URL is invalid");
218
+ }
219
+ const hostname = url.hostname.toLowerCase();
220
+ const trustedHost = hostname.endsWith(".salesforce.com") || hostname.endsWith(".salesforce.mil");
221
+ if (url.protocol !== "https:" || !trustedHost || url.username || url.password || url.port || url.pathname !== "" && url.pathname !== "/" || url.search || url.hash) throw new TypeError("Salesforce instance URL must be a canonical HTTPS Salesforce origin");
222
+ return url.origin;
223
+ }
224
+ function validateSalesforceApiVersion(value) {
225
+ if (typeof value !== "string" || !API_VERSION_PATTERN.test(value)) throw new TypeError("Salesforce API version must use the numeric form 61.0");
226
+ return value;
227
+ }
228
+ function validateAccessToken(value) {
229
+ if (typeof value !== "string" || value.length < 1 || value.length > MAX_ACCESS_TOKEN_LENGTH || value !== value.trim() || /[\r\n]/u.test(value)) throw new TypeError("Salesforce access token is invalid");
230
+ return value;
231
+ }
232
+ function validateTimeout(value) {
233
+ if (!Number.isInteger(value) || value < 1e3 || value > 12e4) throw new RangeError("Salesforce request timeout must be between 1000 and 120000 milliseconds");
234
+ return value;
235
+ }
236
+ function encodeRequestBody(body) {
237
+ let encoded;
238
+ try {
239
+ encoded = JSON.stringify(body);
240
+ } catch (error) {
241
+ throw new Error("Salesforce request body is not valid JSON", { cause: error });
242
+ }
243
+ if (Buffer.byteLength(encoded, "utf-8") > 1048576) throw new Error(`Salesforce request body exceeds ${String(MAX_REQUEST_BODY_BYTES)} bytes`);
244
+ return encoded;
245
+ }
246
+ async function readResponseText(response, maxBytes) {
247
+ const declaredLength = response.headers.get("content-length");
248
+ if (declaredLength !== null) {
249
+ const length = Number(declaredLength);
250
+ if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) throw new Error(`Salesforce response exceeds ${String(maxBytes)} bytes`);
251
+ }
252
+ if (!response.body) return "";
253
+ const reader = response.body.getReader();
254
+ const chunks = [];
255
+ let total = 0;
256
+ try {
257
+ for (;;) {
258
+ const chunk = await reader.read();
259
+ if (chunk.done) break;
260
+ const value = chunk.value;
261
+ if (value === void 0) throw new Error("Salesforce response stream returned an invalid chunk");
262
+ total += value.byteLength;
263
+ if (total > maxBytes) {
264
+ await reader.cancel("response exceeds limit");
265
+ throw new Error(`Salesforce response exceeds ${String(maxBytes)} bytes`);
266
+ }
267
+ chunks.push(value);
268
+ }
269
+ } finally {
270
+ reader.releaseLock();
271
+ }
272
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total).toString("utf-8");
273
+ }
274
+ function formatApiError(method, pathname, status, body) {
275
+ let detail = "";
276
+ try {
277
+ const parsed = JSON.parse(body);
278
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
279
+ if (isRecord(first)) detail = [typeof first.errorCode === "string" ? first.errorCode.slice(0, 128) : "", typeof first.message === "string" ? first.message.slice(0, 1024) : ""].filter(Boolean).join(": ");
280
+ } catch {}
281
+ return `Salesforce API ${method} ${pathname} failed (${String(status)})${detail ? `: ${detail}` : ""}`;
282
+ }
283
+ function isRecord(value) {
284
+ return typeof value === "object" && value !== null && !Array.isArray(value);
285
+ }
84
286
  //#endregion
85
287
  //#region src/tools/shared.ts
86
- /**
87
- * Shared `orgId` selector field. Every credential-touching tool requires it
88
- * (Pattern A per-call dispatch) one Salesforce OAuth grant maps to one org,
89
- * so the org id is the stable account selector. `McpServer.registerTool`
90
- * expects a raw Zod shape (`{ field: z.string() }`), NOT a JSON-Schema object.
91
- */
92
- const orgIdField = z.string().describe("Salesforce org id — use the value from salesforce_list_orgs to pick which connected org this call should target.");
288
+ const orgIdField = z.coerce.string().trim().regex(/^(?:[A-Za-z0-9]{15}|[A-Za-z0-9]{18})$/u, "Expected a 15- or 18-character Salesforce org id").describe("Salesforce org id — use the value from salesforce_list_orgs to pick which connected org this call should target.");
289
+ const sobjectField = z.coerce.string().trim().min(1).max(80).regex(/^[A-Za-z][A-Za-z0-9_]*$/u, "Expected a Salesforce sObject API name");
290
+ const recordIdField = z.coerce.string().trim().regex(/^(?:[A-Za-z0-9]{15}|[A-Za-z0-9]{18})$/u, "Expected a 15- or 18-character Salesforce record id");
291
+ const queryField = (kind) => z.coerce.string().trim().min(1).max(MAX_QUERY_CHARS).describe(`The ${kind} query string.`);
292
+ const fieldName = z.coerce.string().trim().min(1).max(80).regex(/^[A-Za-z][A-Za-z0-9_]*$/u, "Expected a Salesforce field API name");
293
+ const fieldList = z.array(fieldName).min(1).max(200).refine((fields) => new Set(fields).size === fields.length, "Field API names must be unique").optional();
294
+ const recordFields = z.record(fieldName, z.unknown()).superRefine((value, context) => {
295
+ try {
296
+ validateRecordFields(value);
297
+ } catch (error) {
298
+ context.addIssue({
299
+ code: "custom",
300
+ message: error instanceof Error ? error.message : "Invalid Salesforce record fields"
301
+ });
302
+ }
303
+ });
93
304
  //#endregion
94
305
  //#region src/tools/data.ts
95
306
  /**
@@ -105,50 +316,70 @@ function registerDataTools(server, resolveClient) {
105
316
  }] });
106
317
  register("salesforce_query", {
107
318
  description: "Run a SOQL query against a connected Salesforce org. Works for any standard or custom object, e.g. \"SELECT Id, Name FROM Account WHERE Industry = 'Technology' LIMIT 50\".",
319
+ annotations: {
320
+ readOnlyHint: true,
321
+ idempotentHint: true
322
+ },
108
323
  inputSchema: {
109
324
  orgId: orgIdField,
110
- soql: z.string().describe("The SOQL query string.")
325
+ soql: queryField("SOQL")
111
326
  }
112
327
  }, async (args) => {
113
328
  return ok(await resolveClient(args.orgId).query(args.soql));
114
329
  });
115
330
  register("salesforce_search", {
116
331
  description: "Run a SOSL search across a connected Salesforce org, e.g. \"FIND {Acme} IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, Name)\".",
332
+ annotations: {
333
+ readOnlyHint: true,
334
+ idempotentHint: true
335
+ },
117
336
  inputSchema: {
118
337
  orgId: orgIdField,
119
- sosl: z.string().describe("The SOSL search string.")
338
+ sosl: queryField("SOSL")
120
339
  }
121
340
  }, async (args) => {
122
341
  return ok(await resolveClient(args.orgId).search(args.sosl));
123
342
  });
124
343
  register("salesforce_get_record", {
125
344
  description: "Fetch a single record by id from a connected Salesforce org.",
345
+ annotations: {
346
+ readOnlyHint: true,
347
+ idempotentHint: true
348
+ },
126
349
  inputSchema: {
127
350
  orgId: orgIdField,
128
- sobject: z.string().describe("The sObject API name, e.g. Account, Contact, Opportunity, or MyObject__c."),
129
- id: z.string().describe("The 15- or 18-character record id."),
130
- fields: z.array(z.string()).optional().describe("Optional subset of field API names to return. Omit for all accessible fields.")
351
+ sobject: sobjectField.describe("The sObject API name, e.g. Account, Contact, Opportunity, or MyObject__c."),
352
+ id: recordIdField.describe("The 15- or 18-character record id."),
353
+ fields: fieldList.describe("Optional subset of field API names to return. Omit for all accessible fields.")
131
354
  }
132
355
  }, async (args) => {
133
356
  return ok(await resolveClient(args.orgId).getRecord(args.sobject, args.id, args.fields));
134
357
  });
135
358
  register("salesforce_create_record", {
136
359
  description: "Create a record of any sObject type in a connected Salesforce org.",
360
+ annotations: {
361
+ readOnlyHint: false,
362
+ idempotentHint: false
363
+ },
137
364
  inputSchema: {
138
365
  orgId: orgIdField,
139
- sobject: z.string().describe("The sObject API name, e.g. Account, Contact, Lead, Case, or MyObject__c."),
140
- fields: z.record(z.string(), z.unknown()).describe("Field API name → value map, e.g. { \"Name\": \"Acme Inc\", \"Industry\": \"Technology\" }.")
366
+ sobject: sobjectField.describe("The sObject API name, e.g. Account, Contact, Lead, Case, or MyObject__c."),
367
+ fields: recordFields.describe("Field API name → value map, e.g. { \"Name\": \"Acme Inc\", \"Industry\": \"Technology\" }.")
141
368
  }
142
369
  }, async (args) => {
143
370
  return ok(await resolveClient(args.orgId).createRecord(args.sobject, args.fields));
144
371
  });
145
372
  register("salesforce_update_record", {
146
373
  description: "Update fields on an existing record in a connected Salesforce org. Returns no body on success.",
374
+ annotations: {
375
+ readOnlyHint: false,
376
+ idempotentHint: true
377
+ },
147
378
  inputSchema: {
148
379
  orgId: orgIdField,
149
- sobject: z.string().describe("The sObject API name."),
150
- id: z.string().describe("The record id to update."),
151
- fields: z.record(z.string(), z.unknown()).describe("Field API name → new value map. Only the supplied fields are changed.")
380
+ sobject: sobjectField.describe("The sObject API name."),
381
+ id: recordIdField.describe("The record id to update."),
382
+ fields: recordFields.describe("Field API name → new value map. Only the supplied fields are changed.")
152
383
  }
153
384
  }, async (args) => {
154
385
  await resolveClient(args.orgId).updateRecord(args.sobject, args.id, args.fields);
@@ -158,13 +389,20 @@ function registerDataTools(server, resolveClient) {
158
389
  });
159
390
  });
160
391
  register("salesforce_delete_record", {
161
- description: "Delete a record by id from a connected Salesforce org. Returns no body on success.",
392
+ description: "Permanently delete a Salesforce record. Copy the exact id into confirmRecordId to confirm the destructive target.",
393
+ annotations: {
394
+ readOnlyHint: false,
395
+ destructiveHint: true,
396
+ idempotentHint: true
397
+ },
162
398
  inputSchema: {
163
399
  orgId: orgIdField,
164
- sobject: z.string().describe("The sObject API name."),
165
- id: z.string().describe("The record id to delete.")
400
+ sobject: sobjectField.describe("The sObject API name."),
401
+ id: recordIdField.describe("The record id to delete."),
402
+ confirmRecordId: recordIdField.describe("Exact record id confirming the permanent deletion target.")
166
403
  }
167
404
  }, async (args) => {
405
+ if (args.confirmRecordId !== args.id) throw new Error("confirmRecordId must exactly match the record id being deleted");
168
406
  await resolveClient(args.orgId).deleteRecord(args.sobject, args.id);
169
407
  return ok({
170
408
  id: args.id,
@@ -173,9 +411,13 @@ function registerDataTools(server, resolveClient) {
173
411
  });
174
412
  register("salesforce_describe", {
175
413
  description: "Describe an sObject's metadata (fields, types, picklist values, relationships) for a connected Salesforce org. Use this to discover field API names before querying or creating records.",
414
+ annotations: {
415
+ readOnlyHint: true,
416
+ idempotentHint: true
417
+ },
176
418
  inputSchema: {
177
419
  orgId: orgIdField,
178
- sobject: z.string().describe("The sObject API name to describe, e.g. Account or MyObject__c.")
420
+ sobject: sobjectField.describe("The sObject API name to describe, e.g. Account or MyObject__c.")
179
421
  }
180
422
  }, async (args) => {
181
423
  return ok(await resolveClient(args.orgId).describe(args.sobject));
@@ -199,6 +441,7 @@ function registerDataTools(server, resolveClient) {
199
441
  * Architecture:
200
442
  * OpenClaw ←(stdio)→ this server ←(https)→ Salesforce REST API
201
443
  */
444
+ const packageMetadata = createRequire(import.meta.url)("../package.json");
202
445
  /**
203
446
  * Salesforce REST API version. The SOQL / sObject CRUD / describe surface is
204
447
  * version-stable, so this defaults to a widely-available version and can be
@@ -218,11 +461,12 @@ function log(msg) {
218
461
  * the LLM to ask the user to reconnect rather than retrying blindly.
219
462
  */
220
463
  function resolveClient(orgId) {
221
- const c = clients.get(orgId);
464
+ const normalizedOrgId = validateOrgId(orgId);
465
+ const c = clients.get(normalizedOrgId);
222
466
  if (c) return c;
223
- const known = orgSnapshot.find((o) => o.orgId === orgId);
224
- if (known && !known.connected) throw new Error(`orgId ${orgId} is connected on this agent but the server could not initialise a client for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Salesforce org from the dashboard.`);
225
- throw new Error(`Unknown orgId: ${orgId}. Call salesforce_list_orgs to see the connected Salesforce orgs on this agent.`);
467
+ const known = orgSnapshot.find((o) => o.orgId === normalizedOrgId);
468
+ if (known && !known.connected) throw new Error(`orgId ${normalizedOrgId} is connected on this agent but the server could not initialise a client for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Salesforce org from the dashboard.`);
469
+ throw new Error(`Unknown orgId: ${normalizedOrgId}. Call salesforce_list_orgs to see the connected Salesforce orgs on this agent.`);
226
470
  }
227
471
  /**
228
472
  * Refresh a single org's access token (Salesforce tokens are per-org).
@@ -250,15 +494,28 @@ async function main() {
250
494
  apiKey: config.apiKey,
251
495
  apiUrl: config.apiUrl
252
496
  });
253
- const apiVersion = process.env.SALESFORCE_API_VERSION ?? DEFAULT_API_VERSION;
497
+ const apiVersion = validateSalesforceApiVersion(process.env.SALESFORCE_API_VERSION ?? DEFAULT_API_VERSION);
254
498
  const { accounts } = await apiClient.getSalesforceAccounts();
255
499
  if (accounts.length === 0) log("No Salesforce orgs connected — server will start with salesforce_list_orgs + salesforce_refresh_token only");
500
+ const seenOrgIds = /* @__PURE__ */ new Set();
256
501
  for (const acct of accounts) {
502
+ let orgId;
503
+ try {
504
+ orgId = validateOrgId(acct.orgId);
505
+ } catch {
506
+ log("Skipping Salesforce account with an invalid org id");
507
+ continue;
508
+ }
509
+ if (seenOrgIds.has(orgId)) {
510
+ log(`Duplicate orgId ${orgId} returned by getSalesforceAccounts() — keeping the first account`);
511
+ continue;
512
+ }
513
+ seenOrgIds.add(orgId);
257
514
  if (!acct.accessToken || !acct.instanceUrl) {
258
- log(`Skipping org ${acct.orgId} — missing access token or instance URL`);
515
+ log(`Skipping org ${orgId} — missing access token or instance URL`);
259
516
  orgSnapshot.push({
260
- orgId: acct.orgId,
261
- instanceUrl: acct.instanceUrl,
517
+ orgId,
518
+ instanceUrl: "",
262
519
  displayName: acct.displayName,
263
520
  connectedAt: acct.connectedAt,
264
521
  connected: false,
@@ -266,34 +523,53 @@ async function main() {
266
523
  });
267
524
  continue;
268
525
  }
269
- if (clients.has(acct.orgId)) {
270
- log(`Duplicate orgId ${acct.orgId} returned by getSalesforceAccounts() — keeping the first cached client`);
526
+ let instanceUrl;
527
+ let client;
528
+ try {
529
+ instanceUrl = validateSalesforceInstanceUrl(acct.instanceUrl);
530
+ client = new SalesforceClient({
531
+ accessToken: acct.accessToken,
532
+ instanceUrl,
533
+ apiVersion,
534
+ onRefresh: async () => {
535
+ return { accessToken: await refreshOrg(apiClient, orgId) };
536
+ }
537
+ });
538
+ } catch {
539
+ log(`Skipping org ${orgId} — invalid Salesforce credential metadata`);
540
+ orgSnapshot.push({
541
+ orgId,
542
+ instanceUrl: "",
543
+ displayName: acct.displayName,
544
+ connectedAt: acct.connectedAt,
545
+ connected: false,
546
+ reason: "invalid_credential_metadata"
547
+ });
271
548
  continue;
272
549
  }
273
- clients.set(acct.orgId, new SalesforceClient({
274
- accessToken: acct.accessToken,
275
- instanceUrl: acct.instanceUrl,
276
- apiVersion,
277
- onRefresh: async () => {
278
- return { accessToken: await refreshOrg(apiClient, acct.orgId) };
279
- }
280
- }));
550
+ clients.set(orgId, client);
281
551
  orgSnapshot.push({
282
- orgId: acct.orgId,
283
- instanceUrl: acct.instanceUrl,
552
+ orgId,
553
+ instanceUrl,
284
554
  displayName: acct.displayName,
285
555
  connectedAt: acct.connectedAt,
286
556
  connected: true
287
557
  });
288
- log(`Cached client for org ${acct.orgId} (${acct.displayName ?? "no display name"})`);
558
+ log(`Cached client for org ${orgId}`);
289
559
  }
290
560
  const server = new McpServer({
291
561
  name: "salesforce-mcp-server",
292
- version: "1.0.0"
562
+ version: packageMetadata.version
293
563
  });
294
564
  registerDataTools(server, resolveClient);
295
565
  const registerTool = server.registerTool.bind(server);
296
- registerTool("salesforce_list_orgs", { description: "List the Salesforce orgs the agent has connected. Returns one entry per OAuth connection — use the returned orgId values as the `orgId` selector arg on every other salesforce_* tool." }, () => {
566
+ registerTool("salesforce_list_orgs", {
567
+ description: "List the Salesforce orgs the agent has connected. Returns one entry per OAuth connection — use the returned orgId values as the `orgId` selector arg on every other salesforce_* tool.",
568
+ annotations: {
569
+ readOnlyHint: true,
570
+ idempotentHint: true
571
+ }
572
+ }, () => {
297
573
  return { content: [{
298
574
  type: "text",
299
575
  text: JSON.stringify({ orgs: orgSnapshot }, null, 2)
@@ -301,7 +577,11 @@ async function main() {
301
577
  });
302
578
  registerTool("salesforce_refresh_token", {
303
579
  description: "Force-refresh the Salesforce OAuth2 access token for a connected org. Pass an orgId to refresh that org, or omit it to refresh every connected org. Use this if API calls are failing with authentication errors.",
304
- inputSchema: { orgId: z.string().optional().describe("Optional Salesforce org id — omit to refresh every connected org.") }
580
+ annotations: {
581
+ readOnlyHint: false,
582
+ idempotentHint: true
583
+ },
584
+ inputSchema: { orgId: orgIdField.optional().describe("Optional Salesforce org id — omit to refresh every connected org.") }
305
585
  }, async (args) => {
306
586
  try {
307
587
  if (args.orgId) resolveClient(args.orgId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/salesforce-mcp",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Salesforce MCP server — direct REST/SOQL integration with Alfe OAuth credentials and automatic per-org token refresh",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
21
21
  "zod": "^4.0.5",
22
- "@alfe.ai/config": "0.3.0",
23
- "@alfe.ai/agent-api-client": "0.13.0"
22
+ "@alfe.ai/config": "0.4.1",
23
+ "@alfe.ai/agent-api-client": "0.15.0"
24
24
  },
25
25
  "license": "UNLICENSED",
26
26
  "homepage": "https://alfe.ai",
@@ -36,6 +36,7 @@
36
36
  "scripts": {
37
37
  "build": "tsdown",
38
38
  "dev": "tsdown --watch",
39
+ "test": "vitest run",
39
40
  "typecheck": "tsc --noEmit",
40
41
  "lint": "eslint ."
41
42
  }