@mindstone/mcp-server-salesforce 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/dist/client.js +2 -1
- package/dist/server.js +7 -1
- package/dist/tools/accounts.js +4 -4
- package/dist/tools/auth-tools.js +47 -12
- package/dist/tools/campaigns.d.ts +3 -0
- package/dist/tools/campaigns.js +78 -0
- package/dist/tools/cases.d.ts +3 -0
- package/dist/tools/cases.js +121 -0
- package/dist/tools/contacts.js +4 -4
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.js +90 -0
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/leads.js +16 -8
- package/dist/tools/notes.d.ts +3 -0
- package/dist/tools/notes.js +111 -0
- package/dist/tools/opportunities.js +4 -4
- package/dist/tools/query.js +13 -10
- package/dist/tools/reports.d.ts +3 -0
- package/dist/tools/reports.js +39 -0
- package/dist/tools/search.d.ts +3 -0
- package/dist/tools/search.js +50 -0
- package/dist/tools/tasks.js +4 -4
- package/dist/tools/users.js +2 -2
- package/dist/types.d.ts +8 -0
- package/dist/types.js +8 -0
- package/dist/untrusted-content.d.ts +37 -0
- package/dist/untrusted-content.js +70 -0
- package/dist/utils.d.ts +34 -0
- package/dist/utils.js +130 -3
- package/package.json +1 -1
package/dist/tools/leads.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, checkSaveResult } from '../utils.js';
|
|
2
|
+
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords, sanitizeExternalData } from '../utils.js';
|
|
3
3
|
import { withConnection } from '../client.js';
|
|
4
4
|
import { ConnectorError } from '../types.js';
|
|
5
5
|
export function registerLeadTools(server) {
|
|
@@ -35,7 +35,7 @@ export function registerLeadTools(server) {
|
|
|
35
35
|
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
36
36
|
query += ` LIMIT ${limit}`;
|
|
37
37
|
const result = await conn.query(query);
|
|
38
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize });
|
|
38
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_leads:records'), totalSize: result.totalSize });
|
|
39
39
|
});
|
|
40
40
|
}));
|
|
41
41
|
server.registerTool('salesforce_create_lead', {
|
|
@@ -50,7 +50,7 @@ export function registerLeadTools(server) {
|
|
|
50
50
|
status: z.string().optional().describe('Lead status'),
|
|
51
51
|
fields: z.record(z.unknown()).optional().describe('Additional/custom fields'),
|
|
52
52
|
}),
|
|
53
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
53
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
54
54
|
}, withErrorHandling(async (args) => {
|
|
55
55
|
return withConnection(undefined, async (conn) => {
|
|
56
56
|
const data = { LastName: args.last_name, Company: args.company };
|
|
@@ -68,7 +68,7 @@ export function registerLeadTools(server) {
|
|
|
68
68
|
validateAndMergeCustomFields(data, args.fields);
|
|
69
69
|
const result = await conn.sobject('Lead').create(data);
|
|
70
70
|
if (!result.success)
|
|
71
|
-
throw new ConnectorError('Failed to create lead', 'CREATE_ERROR',
|
|
71
|
+
throw new ConnectorError('Failed to create lead', 'CREATE_ERROR', formatVendorErrors(result.errors));
|
|
72
72
|
return JSON.stringify({ ok: true, status: 'success', object: 'Lead', id: result.id, name: `${args.first_name || ''} ${args.last_name}`.trim() });
|
|
73
73
|
});
|
|
74
74
|
}));
|
|
@@ -79,7 +79,7 @@ export function registerLeadTools(server) {
|
|
|
79
79
|
create_opportunity: z.boolean().optional().describe('Create opportunity (default: true)'),
|
|
80
80
|
opportunity_name: z.string().optional().describe('Name for new opportunity'),
|
|
81
81
|
}),
|
|
82
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
82
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
83
83
|
}, withErrorHandling(async (args) => {
|
|
84
84
|
return withConnection(undefined, async (conn) => {
|
|
85
85
|
const leadConvert = {
|
|
@@ -87,9 +87,17 @@ export function registerLeadTools(server) {
|
|
|
87
87
|
convertedStatus: 'Closed - Converted',
|
|
88
88
|
doNotCreateOpportunity: args.create_opportunity === false,
|
|
89
89
|
};
|
|
90
|
-
const result = await conn
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
const result = await conn.soap.convertLead([leadConvert]);
|
|
91
|
+
const convertResult = result[0];
|
|
92
|
+
// SOAP convertLead reports record-level failures IN THE RESULT
|
|
93
|
+
// (success:false + errors[]), not as a fault — checking success is the
|
|
94
|
+
// only way to avoid reporting a conversion that never happened. The
|
|
95
|
+
// error messages are org-authored validation-rule text, so they are
|
|
96
|
+
// enveloped like every other vendor error (invariant #6).
|
|
97
|
+
if (!convertResult.success) {
|
|
98
|
+
throw new ConnectorError('Failed to convert lead', 'CONVERT_ERROR', formatVendorErrors(convertResult.errors));
|
|
99
|
+
}
|
|
100
|
+
return JSON.stringify({ ok: true, status: 'success', result: sanitizeExternalData(convertResult, 'salesforce:convert_lead:result') });
|
|
93
101
|
});
|
|
94
102
|
}));
|
|
95
103
|
server.registerTool('salesforce_update_lead', {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { withErrorHandling, escapeSOQL, sanitizeRecords, formatVendorErrors } from '../utils.js';
|
|
3
|
+
import { withConnection } from '../client.js';
|
|
4
|
+
import { ConnectorError } from '../types.js';
|
|
5
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
6
|
+
export function registerNoteTools(server) {
|
|
7
|
+
server.registerTool('salesforce_get_notes', {
|
|
8
|
+
description: `Get notes attached to a record (ContentNote linked via ContentDocumentLink). Required: parent_id (Account, Contact, Opportunity, Case, Lead, or any record ID). Returns note Id, Title, TextPreview, CreatedDate, OwnerId; set include_body=true to also return the full note text. Max 200 (default: 50).`,
|
|
9
|
+
inputSchema: z.object({
|
|
10
|
+
parent_id: z.string().min(1).describe('Record ID the notes are attached to (required)'),
|
|
11
|
+
include_body: z.boolean().optional().describe('Include full note body text (default: false — returns title and preview only)'),
|
|
12
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max results 1-200 (default: 50)'),
|
|
13
|
+
}),
|
|
14
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
15
|
+
}, withErrorHandling(async (args) => {
|
|
16
|
+
return withConnection(undefined, async (conn) => {
|
|
17
|
+
const fields = ['Id', 'Title', 'TextPreview', 'CreatedDate', 'LastModifiedDate', 'OwnerId'];
|
|
18
|
+
if (args.include_body)
|
|
19
|
+
fields.push('Content');
|
|
20
|
+
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
21
|
+
const query = `SELECT ${fields.join(', ')} FROM ContentNote` +
|
|
22
|
+
` WHERE Id IN (SELECT ContentDocumentId FROM ContentDocumentLink WHERE LinkedEntityId = '${escapeSOQL(args.parent_id)}')` +
|
|
23
|
+
` ORDER BY CreatedDate DESC LIMIT ${limit}`;
|
|
24
|
+
const result = await conn.query(query);
|
|
25
|
+
// ContentNote.Content is base64-encoded UTF-8; decode it for the model.
|
|
26
|
+
// The raw base64 is never returned — decode it when requested, drop it
|
|
27
|
+
// otherwise.
|
|
28
|
+
const records = result.records.map((record) => {
|
|
29
|
+
const { Content, ...rest } = record;
|
|
30
|
+
if (args.include_body && typeof Content === 'string') {
|
|
31
|
+
let body;
|
|
32
|
+
try {
|
|
33
|
+
body = Buffer.from(Content, 'base64').toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
body = Content;
|
|
37
|
+
}
|
|
38
|
+
return { ...rest, body };
|
|
39
|
+
}
|
|
40
|
+
return rest;
|
|
41
|
+
});
|
|
42
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(records, 'salesforce:get_notes:records'), totalSize: result.totalSize });
|
|
43
|
+
});
|
|
44
|
+
}));
|
|
45
|
+
server.registerTool('salesforce_create_note', {
|
|
46
|
+
description: `Create a note (ContentNote). Required: title, body. Optional: parent_id — a record ID to attach the note to (Account, Contact, Opportunity, Case, Lead, etc.).`,
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
title: z.string().min(1).describe('Note title (required)'),
|
|
49
|
+
body: z.string().min(1).describe('Note body text (required)'),
|
|
50
|
+
parent_id: z.string().optional().describe('Record ID to attach the note to'),
|
|
51
|
+
}),
|
|
52
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
53
|
+
}, withErrorHandling(async (args) => {
|
|
54
|
+
return withConnection(undefined, async (conn) => {
|
|
55
|
+
const noteResult = await conn.sobject('ContentNote').create({
|
|
56
|
+
Title: args.title,
|
|
57
|
+
Content: Buffer.from(args.body, 'utf8').toString('base64'),
|
|
58
|
+
});
|
|
59
|
+
if (!noteResult.success)
|
|
60
|
+
throw new ConnectorError('Failed to create note', 'CREATE_ERROR', formatVendorErrors(noteResult.errors));
|
|
61
|
+
let linkedTo;
|
|
62
|
+
if (args.parent_id) {
|
|
63
|
+
// jsforce throws (HttpApiError) when the link create is rejected — a
|
|
64
|
+
// SaveResult with success:false only surfaces for non-error statuses.
|
|
65
|
+
// Either way the note already exists at this point, and a failed link
|
|
66
|
+
// must not leave an orphaned, unattached note behind.
|
|
67
|
+
let linkResult;
|
|
68
|
+
let linkError;
|
|
69
|
+
try {
|
|
70
|
+
linkResult = await conn.sobject('ContentDocumentLink').create({
|
|
71
|
+
ContentDocumentId: noteResult.id,
|
|
72
|
+
LinkedEntityId: args.parent_id,
|
|
73
|
+
ShareType: 'V',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Session expiry is handled by withConnection — don't misreport it
|
|
78
|
+
// as a link failure.
|
|
79
|
+
if (error instanceof Error &&
|
|
80
|
+
(error.message.includes('INVALID_SESSION_ID') || error.message.includes('Session expired'))) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
linkError = error;
|
|
84
|
+
}
|
|
85
|
+
if (linkError !== undefined || !linkResult?.success) {
|
|
86
|
+
// Best-effort rollback; if the delete also fails, surface the
|
|
87
|
+
// orphan Id so the caller can clean it up manually.
|
|
88
|
+
let rolledBack = false;
|
|
89
|
+
try {
|
|
90
|
+
const deleteResult = await conn.sobject('ContentNote').destroy(noteResult.id);
|
|
91
|
+
rolledBack = deleteResult.success;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Fall through: the error below reports the orphan Id.
|
|
95
|
+
}
|
|
96
|
+
const linkDetail = linkError instanceof Error
|
|
97
|
+
? wrapUntrusted(linkError.message, 'salesforce:vendor_errors')
|
|
98
|
+
: formatVendorErrors(linkResult?.errors);
|
|
99
|
+
throw new ConnectorError(rolledBack
|
|
100
|
+
? 'Failed to attach the note to the parent record; the unattached note was rolled back'
|
|
101
|
+
: 'Failed to attach the note to the parent record, and cleanup of the unattached note failed', 'LINK_ERROR', rolledBack
|
|
102
|
+
? `Link error: ${linkDetail}`
|
|
103
|
+
: `Delete the orphaned note manually (Id: ${noteResult.id}). Link error: ${linkDetail}`);
|
|
104
|
+
}
|
|
105
|
+
linkedTo = args.parent_id;
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify({ ok: true, status: 'success', object: 'ContentNote', id: noteResult.id, title: args.title, ...(linkedTo ? { linked_to: linkedTo } : {}) });
|
|
108
|
+
});
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=notes.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult } from '../utils.js';
|
|
2
|
+
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
|
|
3
3
|
import { withConnection } from '../client.js';
|
|
4
4
|
import { ConnectorError } from '../types.js';
|
|
5
5
|
export function registerOpportunityTools(server) {
|
|
@@ -36,7 +36,7 @@ export function registerOpportunityTools(server) {
|
|
|
36
36
|
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
37
37
|
query += ` LIMIT ${limit}`;
|
|
38
38
|
const result = await conn.query(query);
|
|
39
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize });
|
|
39
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_opportunities:records'), totalSize: result.totalSize });
|
|
40
40
|
});
|
|
41
41
|
}));
|
|
42
42
|
server.registerTool('salesforce_create_opportunity', {
|
|
@@ -50,7 +50,7 @@ export function registerOpportunityTools(server) {
|
|
|
50
50
|
description: z.string().optional().describe('Description'),
|
|
51
51
|
fields: z.record(z.unknown()).optional().describe('Additional/custom fields as key-value pairs'),
|
|
52
52
|
}),
|
|
53
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
53
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
54
54
|
}, withErrorHandling(async (args) => {
|
|
55
55
|
formatSOQLDate(args.close_date, 'close_date');
|
|
56
56
|
return withConnection(undefined, async (conn) => {
|
|
@@ -65,7 +65,7 @@ export function registerOpportunityTools(server) {
|
|
|
65
65
|
validateAndMergeCustomFields(data, args.fields);
|
|
66
66
|
const result = await conn.sobject('Opportunity').create(data);
|
|
67
67
|
if (!result.success)
|
|
68
|
-
throw new ConnectorError('Failed to create opportunity', 'CREATE_ERROR',
|
|
68
|
+
throw new ConnectorError('Failed to create opportunity', 'CREATE_ERROR', formatVendorErrors(result.errors));
|
|
69
69
|
return JSON.stringify({ ok: true, status: 'success', object: 'Opportunity', id: result.id, name: args.name });
|
|
70
70
|
});
|
|
71
71
|
}));
|
package/dist/tools/query.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { withErrorHandling, validateObjectName, validateFields, isValidQueryFieldName, isValidFieldName, escapeSOQL, ALLOWED_FILTER_OPERATORS, validateAndMergeCustomFields, checkSaveResult } from '../utils.js';
|
|
2
|
+
import { withErrorHandling, validateObjectName, validateFields, isValidQueryFieldName, isValidFieldName, escapeSOQL, ALLOWED_FILTER_OPERATORS, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
|
|
3
3
|
import { withConnection } from '../client.js';
|
|
4
|
+
import { wrapUntrusted } from '../untrusted-content.js';
|
|
4
5
|
import { ConnectorError } from '../types.js';
|
|
5
6
|
// Strip SQL-style line ("// ...") and block ("/* ... ") comments from a
|
|
6
7
|
// SOQL query. SOQL itself does not officially support either form, but
|
|
@@ -130,7 +131,7 @@ export function registerQueryTools(server) {
|
|
|
130
131
|
const MAX_LIMIT = 200;
|
|
131
132
|
const query = applyQueryLimitCap(args.query, MAX_LIMIT);
|
|
132
133
|
const result = await conn.query(query);
|
|
133
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize, done: result.done });
|
|
134
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:query:records'), totalSize: result.totalSize, done: result.done });
|
|
134
135
|
});
|
|
135
136
|
}));
|
|
136
137
|
server.registerTool('salesforce_describe_object', {
|
|
@@ -142,20 +143,22 @@ export function registerQueryTools(server) {
|
|
|
142
143
|
}, withErrorHandling(async (args) => {
|
|
143
144
|
return withConnection(undefined, async (conn) => {
|
|
144
145
|
const metadata = await conn.sobject(args.object_name).describe();
|
|
146
|
+
// Labels and record-type names are org-authored text — envelope them;
|
|
147
|
+
// field API names stay raw (they are identifiers, reused in queries).
|
|
145
148
|
return JSON.stringify({
|
|
146
149
|
ok: true,
|
|
147
150
|
name: metadata.name,
|
|
148
|
-
label: metadata.label,
|
|
149
|
-
labelPlural: metadata.labelPlural,
|
|
151
|
+
label: wrapUntrusted(metadata.label, 'salesforce:describe_object:label'),
|
|
152
|
+
labelPlural: wrapUntrusted(metadata.labelPlural, 'salesforce:describe_object:labelPlural'),
|
|
150
153
|
fields: metadata.fields.map((f) => ({
|
|
151
154
|
name: f.name,
|
|
152
|
-
label: f.label,
|
|
155
|
+
label: wrapUntrusted(f.label, 'salesforce:describe_object:field_label'),
|
|
153
156
|
type: f.type,
|
|
154
157
|
required: !f.nillable && !f.defaultedOnCreate,
|
|
155
158
|
updateable: f.updateable,
|
|
156
159
|
createable: f.createable,
|
|
157
160
|
})),
|
|
158
|
-
recordTypeInfos: metadata.recordTypeInfos,
|
|
161
|
+
recordTypeInfos: sanitizeRecords(metadata.recordTypeInfos, 'salesforce:describe_object:recordTypeInfos'),
|
|
159
162
|
});
|
|
160
163
|
});
|
|
161
164
|
}));
|
|
@@ -170,7 +173,7 @@ export function registerQueryTools(server) {
|
|
|
170
173
|
const result = await conn.describeGlobal();
|
|
171
174
|
let objects = result.sobjects.map((s) => ({
|
|
172
175
|
name: s.name,
|
|
173
|
-
label: s.label,
|
|
176
|
+
label: wrapUntrusted(s.label, 'salesforce:list_objects:label'),
|
|
174
177
|
queryable: s.queryable,
|
|
175
178
|
createable: s.createable,
|
|
176
179
|
updateable: s.updateable,
|
|
@@ -188,7 +191,7 @@ export function registerQueryTools(server) {
|
|
|
188
191
|
object_name: z.string().min(1).describe('sObject API name (e.g., Invoice__c, Case)'),
|
|
189
192
|
fields: z.record(z.unknown()).describe('Field-value pairs to set on the new record'),
|
|
190
193
|
}),
|
|
191
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
194
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
192
195
|
}, withErrorHandling(async (args) => {
|
|
193
196
|
validateObjectName(args.object_name);
|
|
194
197
|
return withConnection(undefined, async (conn) => {
|
|
@@ -196,7 +199,7 @@ export function registerQueryTools(server) {
|
|
|
196
199
|
validateAndMergeCustomFields(data, args.fields);
|
|
197
200
|
const result = await conn.sobject(args.object_name).create(data);
|
|
198
201
|
if (!result.success)
|
|
199
|
-
throw new ConnectorError(`Failed to create ${args.object_name} record`, 'CREATE_ERROR',
|
|
202
|
+
throw new ConnectorError(`Failed to create ${args.object_name} record`, 'CREATE_ERROR', formatVendorErrors(result.errors));
|
|
200
203
|
return JSON.stringify({ ok: true, status: 'success', object: args.object_name, id: result.id });
|
|
201
204
|
});
|
|
202
205
|
}));
|
|
@@ -260,7 +263,7 @@ export function registerQueryTools(server) {
|
|
|
260
263
|
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
261
264
|
soql += ` LIMIT ${limit}`;
|
|
262
265
|
const result = await conn.query(soql);
|
|
263
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize, done: result.done });
|
|
266
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_records:records'), totalSize: result.totalSize, done: result.done });
|
|
264
267
|
});
|
|
265
268
|
}));
|
|
266
269
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { withErrorHandling, sanitizeExternalData } from '../utils.js';
|
|
3
|
+
import { withConnection } from '../client.js';
|
|
4
|
+
import { ConnectorError } from '../types.js';
|
|
5
|
+
export function registerReportTools(server) {
|
|
6
|
+
server.registerTool('salesforce_run_report', {
|
|
7
|
+
description: `Run an existing Salesforce report (Analytics REST API) and return its aggregated result. Required: report_id (the report's 15- or 18-character ID, visible in the report URL). Set include_details=true to include detail rows, not just groupings and aggregates.`,
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
report_id: z.string().min(1).describe('Salesforce report ID (15 or 18 characters, from the report URL)'),
|
|
10
|
+
include_details: z.boolean().optional().describe('Include detail rows in the result (default: false — groupings and aggregates only)'),
|
|
11
|
+
}),
|
|
12
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13
|
+
}, withErrorHandling(async (args) => {
|
|
14
|
+
if (!/^[a-zA-Z0-9]{15,18}$/.test(args.report_id)) {
|
|
15
|
+
throw new ConnectorError(`Invalid report_id: "${args.report_id}"`, 'INVALID_REPORT_ID', 'Report IDs are 15- or 18-character alphanumeric Salesforce IDs (e.g. "00O1a000005xYZ1AAM"), visible at the end of the report URL');
|
|
16
|
+
}
|
|
17
|
+
return withConnection(undefined, async (conn) => {
|
|
18
|
+
const result = await conn.analytics
|
|
19
|
+
.report(args.report_id)
|
|
20
|
+
.execute({ details: args.include_details === true });
|
|
21
|
+
// Everything in a report result (name, group labels, cell values) is
|
|
22
|
+
// org-authored text — envelope it. factMap keys (T!T, 0!0) are
|
|
23
|
+
// structural and stay raw; object keys are never enveloped.
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
ok: true,
|
|
26
|
+
report: sanitizeExternalData({
|
|
27
|
+
reportMetadata: result.reportMetadata,
|
|
28
|
+
reportExtendedMetadata: result.reportExtendedMetadata,
|
|
29
|
+
factMap: result.factMap,
|
|
30
|
+
groupingsDown: result.groupingsDown,
|
|
31
|
+
groupingsAcross: result.groupingsAcross,
|
|
32
|
+
hasDetailRows: result.hasDetailRows,
|
|
33
|
+
allData: result.allData,
|
|
34
|
+
}, 'salesforce:run_report:report'),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=reports.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { withErrorHandling, escapeSOSL, sanitizeRecords } from '../utils.js';
|
|
3
|
+
import { withConnection } from '../client.js';
|
|
4
|
+
// Allowlisted object names + fixed RETURNING field lists: the only caller
|
|
5
|
+
// input reaching the SOSL string is the (escaped) search term, so object and
|
|
6
|
+
// field names can never be an injection vector.
|
|
7
|
+
const SEARCHABLE_OBJECTS = {
|
|
8
|
+
Account: 'Account(Id, Name, Industry, Type)',
|
|
9
|
+
Contact: 'Contact(Id, FirstName, LastName, Email, Title, AccountId)',
|
|
10
|
+
Lead: 'Lead(Id, FirstName, LastName, Company, Email, Status)',
|
|
11
|
+
Opportunity: 'Opportunity(Id, Name, StageName, Amount, CloseDate, AccountId)',
|
|
12
|
+
Case: 'Case(Id, CaseNumber, Subject, Status, Priority)',
|
|
13
|
+
Task: 'Task(Id, Subject, Status, Priority, ActivityDate)',
|
|
14
|
+
Event: 'Event(Id, Subject, StartDateTime, EndDateTime)',
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_OBJECTS = ['Account', 'Contact', 'Lead', 'Opportunity'];
|
|
17
|
+
export function registerSearchTools(server) {
|
|
18
|
+
server.registerTool('salesforce_search', {
|
|
19
|
+
description: `Cross-object full-text search (SOSL). Use for "find anything mentioning X" requests — searches names, emails, and other indexed text fields at once. Defaults to Account, Contact, Lead, Opportunity; pass objects to widen or narrow. Max 200 results; the response's "truncated" flag is true when more matches exist beyond the limit.`,
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
search_term: z.string().min(2).describe('Text to search for (min 2 characters); reserved SOSL characters are escaped automatically'),
|
|
22
|
+
objects: z
|
|
23
|
+
.array(z.enum(['Account', 'Contact', 'Lead', 'Opportunity', 'Case', 'Task', 'Event']))
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Objects to search (default: Account, Contact, Lead, Opportunity)'),
|
|
26
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max total results 1-200 (default: 200)'),
|
|
27
|
+
}),
|
|
28
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
29
|
+
}, withErrorHandling(async (args) => {
|
|
30
|
+
return withConnection(undefined, async (conn) => {
|
|
31
|
+
const objects = (args.objects && args.objects.length > 0 ? args.objects : DEFAULT_OBJECTS);
|
|
32
|
+
const returning = objects.map((o) => SEARCHABLE_OBJECTS[o]).join(', ');
|
|
33
|
+
const limit = Math.min(Math.max(1, args.limit ?? 200), 200);
|
|
34
|
+
// Probe with one extra record so the caller can tell a complete result
|
|
35
|
+
// apart from one clipped at the limit — SOSL returns no total count.
|
|
36
|
+
const sosl = `FIND {${escapeSOSL(args.search_term)}} IN ALL FIELDS RETURNING ${returning} LIMIT ${limit + 1}`;
|
|
37
|
+
const result = await conn.search(sosl);
|
|
38
|
+
const allRecords = result.searchRecords ?? [];
|
|
39
|
+
const truncated = allRecords.length > limit;
|
|
40
|
+
const records = truncated ? allRecords.slice(0, limit) : allRecords;
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
ok: true,
|
|
43
|
+
records: sanitizeRecords(records, 'salesforce:search:records'),
|
|
44
|
+
count: records.length,
|
|
45
|
+
truncated,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=search.js.map
|
package/dist/tools/tasks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult } from '../utils.js';
|
|
2
|
+
import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
|
|
3
3
|
import { withConnection } from '../client.js';
|
|
4
4
|
import { ConnectorError } from '../types.js';
|
|
5
5
|
export function registerTaskTools(server) {
|
|
@@ -42,7 +42,7 @@ export function registerTaskTools(server) {
|
|
|
42
42
|
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
43
43
|
query += ` LIMIT ${limit}`;
|
|
44
44
|
const result = await conn.query(query);
|
|
45
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize });
|
|
45
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_tasks:records'), totalSize: result.totalSize });
|
|
46
46
|
});
|
|
47
47
|
}));
|
|
48
48
|
server.registerTool('salesforce_create_task', {
|
|
@@ -58,7 +58,7 @@ export function registerTaskTools(server) {
|
|
|
58
58
|
owner_id: z.string().optional().describe('Owner User ID'),
|
|
59
59
|
fields: z.record(z.unknown()).optional().describe('Additional/custom fields'),
|
|
60
60
|
}),
|
|
61
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
61
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
62
62
|
}, withErrorHandling(async (args) => {
|
|
63
63
|
if (args.activity_date)
|
|
64
64
|
formatSOQLDate(args.activity_date, 'activity_date');
|
|
@@ -82,7 +82,7 @@ export function registerTaskTools(server) {
|
|
|
82
82
|
validateAndMergeCustomFields(data, args.fields);
|
|
83
83
|
const result = await conn.sobject('Task').create(data);
|
|
84
84
|
if (!result.success)
|
|
85
|
-
throw new ConnectorError('Failed to create task', 'CREATE_ERROR',
|
|
85
|
+
throw new ConnectorError('Failed to create task', 'CREATE_ERROR', formatVendorErrors(result.errors));
|
|
86
86
|
return JSON.stringify({ ok: true, status: 'success', object: 'Task', id: result.id, subject: args.subject });
|
|
87
87
|
});
|
|
88
88
|
}));
|
package/dist/tools/users.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { withErrorHandling, escapeSOQLLike, validateFields, isValidQueryFieldName } from '../utils.js';
|
|
2
|
+
import { withErrorHandling, escapeSOQLLike, validateFields, isValidQueryFieldName, sanitizeRecords } from '../utils.js';
|
|
3
3
|
import { withConnection } from '../client.js';
|
|
4
4
|
export function registerUserTools(server) {
|
|
5
5
|
server.registerTool('salesforce_get_users', {
|
|
@@ -32,7 +32,7 @@ export function registerUserTools(server) {
|
|
|
32
32
|
const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
|
|
33
33
|
query += ` LIMIT ${limit}`;
|
|
34
34
|
const result = await conn.query(query);
|
|
35
|
-
return JSON.stringify({ ok: true, records: result.records, totalSize: result.totalSize });
|
|
35
|
+
return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_users:records'), totalSize: result.totalSize });
|
|
36
36
|
});
|
|
37
37
|
}));
|
|
38
38
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
2
2
|
export declare const SERVER_NAME = "salesforce-mcp-server";
|
|
3
|
+
/**
|
|
4
|
+
* Salesforce REST API version the connector pins every request to. Pinned
|
|
5
|
+
* deliberately rather than riding jsforce's bundled default (v50.0): v66.0 is
|
|
6
|
+
* Spring '26 — one release behind current (v67.0, Summer '26) so orgs still on
|
|
7
|
+
* the previous release accept it. Raise this deliberately, with tests run,
|
|
8
|
+
* when newer API behavior is needed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SALESFORCE_API_VERSION = "66.0";
|
|
3
11
|
/** Server version reported on MCP `initialize`. Read from package.json so
|
|
4
12
|
* it cannot drift from the published npm version. */
|
|
5
13
|
export declare const SERVER_VERSION: string;
|
package/dist/types.js
CHANGED
|
@@ -3,6 +3,14 @@ const require = createRequire(import.meta.url);
|
|
|
3
3
|
const pkg = require('../package.json');
|
|
4
4
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
5
|
export const SERVER_NAME = 'salesforce-mcp-server';
|
|
6
|
+
/**
|
|
7
|
+
* Salesforce REST API version the connector pins every request to. Pinned
|
|
8
|
+
* deliberately rather than riding jsforce's bundled default (v50.0): v66.0 is
|
|
9
|
+
* Spring '26 — one release behind current (v67.0, Summer '26) so orgs still on
|
|
10
|
+
* the previous release accept it. Raise this deliberately, with tests run,
|
|
11
|
+
* when newer API behavior is needed.
|
|
12
|
+
*/
|
|
13
|
+
export const SALESFORCE_API_VERSION = '66.0';
|
|
6
14
|
/** Server version reported on MCP `initialize`. Read from package.json so
|
|
7
15
|
* it cannot drift from the published npm version. */
|
|
8
16
|
export const SERVER_VERSION = pkg.version;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTS.md security invariant #6 — content fetched from an external system
|
|
3
|
+
* MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>`
|
|
4
|
+
* envelope (with close-tag breakout escaping) before it is returned to the
|
|
5
|
+
* LLM, so the model treats third-party / attacker-controllable text as DATA,
|
|
6
|
+
* not as instructions.
|
|
7
|
+
*
|
|
8
|
+
* This is a VENDORED copy of the escaping-critical half of the shared
|
|
9
|
+
* reference in `test-harness/src/untrusted-content.ts` — connectors cannot
|
|
10
|
+
* `import` the test-harness at runtime (it is a test/dev-only `file:`
|
|
11
|
+
* dependency that is never published into a connector's `dist/`), so the
|
|
12
|
+
* helper lives in the connector's own runtime source. It deliberately omits
|
|
13
|
+
* the `unwrapUntrusted*` helpers (runtime code never unwraps) and does not
|
|
14
|
+
* wrap object keys; it is NOT byte-for-byte identical to the reference. The
|
|
15
|
+
* parts that MUST stay in sync are the breakout defenses: the
|
|
16
|
+
* whitespace/case-tolerant close-tag pattern and the escaping itself — do NOT
|
|
17
|
+
* weaken them back to a simple `replaceAll` (that family misses whitespace /
|
|
18
|
+
* case close-tag variants like `</untrusted-content >` /
|
|
19
|
+
* `</UNTRUSTED-CONTENT>`).
|
|
20
|
+
*
|
|
21
|
+
* `scripts/check-untrusted-coverage.mjs` greps for a reference to
|
|
22
|
+
* `untrusted-content` in any connector that talks to an external system; this
|
|
23
|
+
* file (and the call sites that import from it) is what satisfies that gate.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
27
|
+
* envelope, escaping any embedded close-tag variant so the envelope cannot be
|
|
28
|
+
* broken out of. `undefined` passes through untouched. Idempotent for the same
|
|
29
|
+
* `source`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function wrapUntrusted(text: string | null | undefined, source: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Recursively wrap every string value reachable inside `value`. Object keys are
|
|
34
|
+
* structural and NOT wrapped; non-string leaves pass through unchanged.
|
|
35
|
+
*/
|
|
36
|
+
export declare function wrapUntrustedJsonStrings<T>(value: T, source: string): T;
|
|
37
|
+
//# sourceMappingURL=untrusted-content.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTS.md security invariant #6 — content fetched from an external system
|
|
3
|
+
* MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>`
|
|
4
|
+
* envelope (with close-tag breakout escaping) before it is returned to the
|
|
5
|
+
* LLM, so the model treats third-party / attacker-controllable text as DATA,
|
|
6
|
+
* not as instructions.
|
|
7
|
+
*
|
|
8
|
+
* This is a VENDORED copy of the escaping-critical half of the shared
|
|
9
|
+
* reference in `test-harness/src/untrusted-content.ts` — connectors cannot
|
|
10
|
+
* `import` the test-harness at runtime (it is a test/dev-only `file:`
|
|
11
|
+
* dependency that is never published into a connector's `dist/`), so the
|
|
12
|
+
* helper lives in the connector's own runtime source. It deliberately omits
|
|
13
|
+
* the `unwrapUntrusted*` helpers (runtime code never unwraps) and does not
|
|
14
|
+
* wrap object keys; it is NOT byte-for-byte identical to the reference. The
|
|
15
|
+
* parts that MUST stay in sync are the breakout defenses: the
|
|
16
|
+
* whitespace/case-tolerant close-tag pattern and the escaping itself — do NOT
|
|
17
|
+
* weaken them back to a simple `replaceAll` (that family misses whitespace /
|
|
18
|
+
* case close-tag variants like `</untrusted-content >` /
|
|
19
|
+
* `</UNTRUSTED-CONTENT>`).
|
|
20
|
+
*
|
|
21
|
+
* `scripts/check-untrusted-coverage.mjs` greps for a reference to
|
|
22
|
+
* `untrusted-content` in any connector that talks to an external system; this
|
|
23
|
+
* file (and the call sites that import from it) is what satisfies that gate.
|
|
24
|
+
*/
|
|
25
|
+
const UNTRUSTED_CLOSE_TAG_VARIANT = /<\/untrusted-content\s*>/gi;
|
|
26
|
+
const ESCAPED_UNTRUSTED_CLOSE_TAG = '<\\/untrusted-content>';
|
|
27
|
+
function escapeAttr(s) {
|
|
28
|
+
return s.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
29
|
+
}
|
|
30
|
+
function escapeCloseTagSentinels(s) {
|
|
31
|
+
return s.replace(UNTRUSTED_CLOSE_TAG_VARIANT, ESCAPED_UNTRUSTED_CLOSE_TAG);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Wrap a single untrusted string in an `<untrusted-content source="…">`
|
|
35
|
+
* envelope, escaping any embedded close-tag variant so the envelope cannot be
|
|
36
|
+
* broken out of. `undefined` passes through untouched. Idempotent for the same
|
|
37
|
+
* `source`.
|
|
38
|
+
*/
|
|
39
|
+
export function wrapUntrusted(text, source) {
|
|
40
|
+
if (text === undefined || text === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
const open = `<untrusted-content source="${escapeAttr(source)}">`;
|
|
43
|
+
const close = '</untrusted-content>';
|
|
44
|
+
if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) {
|
|
45
|
+
const inner = text.slice(open.length, text.length - close.length);
|
|
46
|
+
if (!UNTRUSTED_CLOSE_TAG_VARIANT.test(inner)) {
|
|
47
|
+
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0;
|
|
51
|
+
}
|
|
52
|
+
return `${open}${escapeCloseTagSentinels(text)}${close}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Recursively wrap every string value reachable inside `value`. Object keys are
|
|
56
|
+
* structural and NOT wrapped; non-string leaves pass through unchanged.
|
|
57
|
+
*/
|
|
58
|
+
export function wrapUntrustedJsonStrings(value, source) {
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
return wrapUntrusted(value, source);
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return value.map((item) => wrapUntrustedJsonStrings(item, source));
|
|
64
|
+
}
|
|
65
|
+
if (value && typeof value === 'object') {
|
|
66
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wrapUntrustedJsonStrings(item, source)]));
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=untrusted-content.js.map
|
package/dist/utils.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ export declare function withErrorHandling<T>(fn: (args: T, extra: unknown) => Pr
|
|
|
19
19
|
* dedicated `escapeSOQLLike` helper for an explicit signal of intent.
|
|
20
20
|
*/
|
|
21
21
|
export declare function escapeSOQL(value: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Escape a user-supplied search term for interpolation inside a SOSL
|
|
24
|
+
* `FIND {term}` clause. SOSL gives special meaning to a different character
|
|
25
|
+
* set than SOQL — every reserved character is backslash-escaped so the term
|
|
26
|
+
* is always a single literal token and can never break out of the braces or
|
|
27
|
+
* inject operators (AND/OR groupings, wildcards, field scoping).
|
|
28
|
+
*/
|
|
29
|
+
export declare function escapeSOSL(term: string): string;
|
|
22
30
|
/**
|
|
23
31
|
* Escape a user-supplied substring for interpolation inside a SOQL
|
|
24
32
|
* `LIKE '%...%'` clause. Identical to `escapeSOQL` semantically (both
|
|
@@ -42,6 +50,12 @@ export declare function validateObjectName(name: string): void;
|
|
|
42
50
|
* Format a date string for SOQL (YYYY-MM-DD format required).
|
|
43
51
|
*/
|
|
44
52
|
export declare function formatSOQLDate(dateStr: string, paramName: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Format a date or datetime string as a SOQL datetime literal (UTC).
|
|
55
|
+
* Accepts a plain date ("2026-01-09", treated as midnight UTC) or an ISO 8601
|
|
56
|
+
* datetime ("2026-01-09T14:30:00Z", offsets supported).
|
|
57
|
+
*/
|
|
58
|
+
export declare function formatSOQLDateTime(value: string, paramName: string): string;
|
|
45
59
|
/**
|
|
46
60
|
* Validate field names and return validated or default fields.
|
|
47
61
|
*/
|
|
@@ -51,6 +65,26 @@ export declare function validateFields(fields: string[], defaultFields: string[]
|
|
|
51
65
|
*/
|
|
52
66
|
export declare function validateAndMergeCustomFields(updateData: Record<string, unknown>, fields: Record<string, unknown>): void;
|
|
53
67
|
export declare const ALLOWED_FILTER_OPERATORS: Set<string>;
|
|
68
|
+
/**
|
|
69
|
+
* Vendor error payloads (e.g. `SaveResult.errors`) are org-authored text:
|
|
70
|
+
* validation-rule messages and error bodies are defined in the customer's
|
|
71
|
+
* Salesforce org and can carry arbitrary content. Envelope them before they
|
|
72
|
+
* reach model-visible output (AGENTS.md invariant #6, FOX-3490).
|
|
73
|
+
*/
|
|
74
|
+
export declare function formatVendorErrors(errors: unknown): string;
|
|
75
|
+
/**
|
|
76
|
+
* Envelope every external-text field in a list of Salesforce records before
|
|
77
|
+
* they are returned to the LLM. Only values under `Id`/`*Id` keys that are
|
|
78
|
+
* actually shaped like Salesforce IDs pass through raw, so downstream tool
|
|
79
|
+
* calls can use them as identifiers.
|
|
80
|
+
*/
|
|
81
|
+
export declare function sanitizeRecords(records: unknown[], source: string): unknown[];
|
|
82
|
+
/**
|
|
83
|
+
* Envelope every string inside an arbitrary external-data blob (report
|
|
84
|
+
* results, metadata payloads), leaving only shape-validated Salesforce IDs
|
|
85
|
+
* raw. Use for non-record response shapes where every value is org-authored.
|
|
86
|
+
*/
|
|
87
|
+
export declare function sanitizeExternalData<T>(value: T, source: string): T;
|
|
54
88
|
export declare function checkSaveResult(result: {
|
|
55
89
|
success: boolean;
|
|
56
90
|
errors?: unknown[];
|