@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.
- package/README.md +11 -0
- package/dist/server.js +362 -82
- 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
|
-
|
|
108
|
+
instanceOrigin;
|
|
11
109
|
apiVersion;
|
|
12
110
|
onRefresh;
|
|
111
|
+
fetchImpl;
|
|
112
|
+
requestTimeoutMs;
|
|
13
113
|
constructor(config) {
|
|
14
|
-
this.accessToken = config.accessToken;
|
|
15
|
-
this.
|
|
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
|
|
23
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
throw new Error(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
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:
|
|
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:
|
|
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:
|
|
129
|
-
id:
|
|
130
|
-
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:
|
|
140
|
-
fields:
|
|
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:
|
|
150
|
-
id:
|
|
151
|
-
fields:
|
|
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: "
|
|
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:
|
|
165
|
-
id:
|
|
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:
|
|
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
|
|
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 ===
|
|
224
|
-
if (known && !known.connected) throw new Error(`orgId ${
|
|
225
|
-
throw new Error(`Unknown orgId: ${
|
|
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 ${
|
|
515
|
+
log(`Skipping org ${orgId} — missing access token or instance URL`);
|
|
259
516
|
orgSnapshot.push({
|
|
260
|
-
orgId
|
|
261
|
-
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
|
-
|
|
270
|
-
|
|
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(
|
|
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
|
|
283
|
-
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 ${
|
|
558
|
+
log(`Cached client for org ${orgId}`);
|
|
289
559
|
}
|
|
290
560
|
const server = new McpServer({
|
|
291
561
|
name: "salesforce-mcp-server",
|
|
292
|
-
version:
|
|
562
|
+
version: packageMetadata.version
|
|
293
563
|
});
|
|
294
564
|
registerDataTools(server, resolveClient);
|
|
295
565
|
const registerTool = server.registerTool.bind(server);
|
|
296
|
-
registerTool("salesforce_list_orgs", {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
-
"@alfe.ai/agent-api-client": "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
|
}
|