delegate-sf-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +20 -0
- package/LICENSE +24 -0
- package/README.md +76 -0
- package/auth.js +148 -0
- package/bin/config-helper.js +51 -0
- package/bin/mcp-salesforce.js +12 -0
- package/bin/setup.js +266 -0
- package/bin/status.js +134 -0
- package/docs/README.md +52 -0
- package/docs/step1.png +0 -0
- package/docs/step2.png +0 -0
- package/docs/step3.png +0 -0
- package/docs/step4.png +0 -0
- package/examples/README.md +35 -0
- package/package.json +16 -0
- package/scripts/README.md +30 -0
- package/src/auth/file-storage.js +447 -0
- package/src/auth/oauth.js +417 -0
- package/src/auth/token-manager.js +207 -0
- package/src/backup/manager.js +949 -0
- package/src/index.js +168 -0
- package/src/salesforce/client.js +388 -0
- package/src/sf-client.js +79 -0
- package/src/tools/auth.js +190 -0
- package/src/tools/backup.js +486 -0
- package/src/tools/create.js +109 -0
- package/src/tools/delegate-hygiene.js +268 -0
- package/src/tools/delegate-validate.js +212 -0
- package/src/tools/delegate-verify.js +143 -0
- package/src/tools/delete.js +72 -0
- package/src/tools/describe.js +132 -0
- package/src/tools/installation-info.js +656 -0
- package/src/tools/learn-context.js +1077 -0
- package/src/tools/learn.js +351 -0
- package/src/tools/query.js +82 -0
- package/src/tools/repair-credentials.js +77 -0
- package/src/tools/setup.js +120 -0
- package/src/tools/time_machine.js +347 -0
- package/src/tools/update.js +138 -0
- package/src/tools.js +214 -0
- package/src/utils/cache.js +120 -0
- package/src/utils/debug.js +52 -0
- package/src/utils/logger.js +19 -0
- package/tokens.json +8 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { logger } from '../utils/debug.js';
|
|
4
|
+
|
|
5
|
+
class SalesforceTimeMachine {
|
|
6
|
+
constructor(backupRootDirectory = './backups') {
|
|
7
|
+
this.backupRootDirectory = backupRootDirectory;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find all available backups sorted by timestamp (newest first)
|
|
12
|
+
*/
|
|
13
|
+
async getAllBackups() {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(this.backupRootDirectory)) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const items = fs.readdirSync(this.backupRootDirectory);
|
|
20
|
+
const backups = [];
|
|
21
|
+
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
const itemPath = path.join(this.backupRootDirectory, item);
|
|
24
|
+
if (fs.statSync(itemPath).isDirectory() && item.startsWith('salesforce-backup-')) {
|
|
25
|
+
const manifestPath = path.join(itemPath, 'backup-manifest.json');
|
|
26
|
+
if (fs.existsSync(manifestPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
29
|
+
backups.push({
|
|
30
|
+
path: itemPath,
|
|
31
|
+
timestamp: manifest.backupInfo.timestamp,
|
|
32
|
+
date: new Date(manifest.backupInfo.timestamp),
|
|
33
|
+
manifest: manifest
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.warn(`Could not read manifest for backup ${item}:`, err.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Sort by timestamp (newest first)
|
|
43
|
+
return backups.sort((a, b) => b.date - a.date);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`Failed to get backups: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find backup closest to a specific date
|
|
51
|
+
*/
|
|
52
|
+
async getBackupAtDate(targetDate) {
|
|
53
|
+
const backups = await this.getAllBackups();
|
|
54
|
+
if (backups.length === 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const target = new Date(targetDate);
|
|
59
|
+
|
|
60
|
+
// Find the backup with timestamp <= target date (most recent backup before target)
|
|
61
|
+
for (const backup of backups) {
|
|
62
|
+
if (backup.date <= target) {
|
|
63
|
+
return backup;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// If no backup before target date, return the oldest backup
|
|
68
|
+
return backups[backups.length - 1];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Query historical data from a specific backup
|
|
73
|
+
*/
|
|
74
|
+
async queryBackupData(backupPath, objectType, filters = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const dataPath = path.join(backupPath, 'data', `${objectType}.json`);
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(dataPath)) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: `No data found for object type '${objectType}' in backup`,
|
|
82
|
+
availableObjects: this.getAvailableObjectTypes(backupPath)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
|
|
87
|
+
let filteredData = data;
|
|
88
|
+
|
|
89
|
+
// Apply filters
|
|
90
|
+
if (Object.keys(filters).length > 0) {
|
|
91
|
+
filteredData = data.filter(record => {
|
|
92
|
+
return Object.entries(filters).every(([field, value]) => {
|
|
93
|
+
if (typeof value === 'string' && value.includes('*')) {
|
|
94
|
+
// Wildcard matching
|
|
95
|
+
const regex = new RegExp(value.replace(/\*/g, '.*'), 'i');
|
|
96
|
+
return regex.test(record[field]);
|
|
97
|
+
}
|
|
98
|
+
return record[field] === value;
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
success: true,
|
|
105
|
+
data: filteredData,
|
|
106
|
+
count: filteredData.length,
|
|
107
|
+
backupTimestamp: this.getBackupTimestamp(backupPath),
|
|
108
|
+
objectType: objectType
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
error: `Failed to query backup data: ${error.message}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Time Machine query: Find data as it existed at a specific point in time
|
|
120
|
+
*/
|
|
121
|
+
async queryAtPointInTime(targetDate, objectType, filters = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const backup = await this.getBackupAtDate(targetDate);
|
|
124
|
+
|
|
125
|
+
if (!backup) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: 'No backups found for the specified date range'
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = await this.queryBackupData(backup.path, objectType, filters);
|
|
133
|
+
|
|
134
|
+
if (result.success) {
|
|
135
|
+
result.snapshotDate = backup.timestamp;
|
|
136
|
+
result.message = `Data as it existed on ${backup.timestamp}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
error: `Time Machine query failed: ${error.message}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compare data between two points in time
|
|
150
|
+
*/
|
|
151
|
+
async compareDataOverTime(startDate, endDate, objectType, filters = {}) {
|
|
152
|
+
try {
|
|
153
|
+
const startBackup = await this.getBackupAtDate(startDate);
|
|
154
|
+
const endBackup = await this.getBackupAtDate(endDate);
|
|
155
|
+
|
|
156
|
+
if (!startBackup || !endBackup) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: 'Could not find backups for the specified date range'
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const startResult = await this.queryBackupData(startBackup.path, objectType, filters);
|
|
164
|
+
const endResult = await this.queryBackupData(endBackup.path, objectType, filters);
|
|
165
|
+
|
|
166
|
+
if (!startResult.success || !endResult.success) {
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: 'Failed to query data from one or both backups'
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Simple comparison - can be enhanced with more sophisticated diff logic
|
|
174
|
+
const comparison = {
|
|
175
|
+
startSnapshot: {
|
|
176
|
+
date: startBackup.timestamp,
|
|
177
|
+
count: startResult.count,
|
|
178
|
+
data: startResult.data
|
|
179
|
+
},
|
|
180
|
+
endSnapshot: {
|
|
181
|
+
date: endBackup.timestamp,
|
|
182
|
+
count: endResult.count,
|
|
183
|
+
data: endResult.data
|
|
184
|
+
},
|
|
185
|
+
changes: {
|
|
186
|
+
countDifference: endResult.count - startResult.count,
|
|
187
|
+
// Add more sophisticated change detection here
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
comparison: comparison,
|
|
194
|
+
objectType: objectType
|
|
195
|
+
};
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
error: `Data comparison failed: ${error.message}`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Search across all backups for historical changes to a specific record
|
|
206
|
+
*/
|
|
207
|
+
async getRecordHistory(recordId, objectType) {
|
|
208
|
+
try {
|
|
209
|
+
const backups = await this.getAllBackups();
|
|
210
|
+
const history = [];
|
|
211
|
+
|
|
212
|
+
for (const backup of backups) {
|
|
213
|
+
const result = await this.queryBackupData(backup.path, objectType, { Id: recordId });
|
|
214
|
+
if (result.success && result.data.length > 0) {
|
|
215
|
+
history.push({
|
|
216
|
+
timestamp: backup.timestamp,
|
|
217
|
+
data: result.data[0] // Should be only one record with specific ID
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
success: true,
|
|
224
|
+
recordId: recordId,
|
|
225
|
+
objectType: objectType,
|
|
226
|
+
history: history,
|
|
227
|
+
changesCount: history.length
|
|
228
|
+
};
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Record history query failed: ${error.message}`
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get available object types from a backup
|
|
239
|
+
*/
|
|
240
|
+
getAvailableObjectTypes(backupPath) {
|
|
241
|
+
try {
|
|
242
|
+
const dataPath = path.join(backupPath, 'data');
|
|
243
|
+
if (!fs.existsSync(dataPath)) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return fs.readdirSync(dataPath)
|
|
248
|
+
.filter(file => file.endsWith('.json'))
|
|
249
|
+
.map(file => file.replace('.json', ''));
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get backup timestamp from manifest
|
|
257
|
+
*/
|
|
258
|
+
getBackupTimestamp(backupPath) {
|
|
259
|
+
try {
|
|
260
|
+
const manifestPath = path.join(backupPath, 'backup-manifest.json');
|
|
261
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
262
|
+
return manifest.backupInfo.timestamp;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* List all available Time Machine operations
|
|
270
|
+
*/
|
|
271
|
+
getAvailableOperations() {
|
|
272
|
+
return [
|
|
273
|
+
{
|
|
274
|
+
operation: 'query_at_point_in_time',
|
|
275
|
+
description: 'Query data as it existed at a specific date',
|
|
276
|
+
parameters: ['targetDate', 'objectType', 'filters (optional)']
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
operation: 'compare_over_time',
|
|
280
|
+
description: 'Compare data between two points in time',
|
|
281
|
+
parameters: ['startDate', 'endDate', 'objectType', 'filters (optional)']
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
operation: 'get_record_history',
|
|
285
|
+
description: 'Get complete history of changes for a specific record',
|
|
286
|
+
parameters: ['recordId', 'objectType']
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
operation: 'list_backups',
|
|
290
|
+
description: 'List all available backup snapshots',
|
|
291
|
+
parameters: []
|
|
292
|
+
}
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// MCP Tool implementations
|
|
298
|
+
const TIME_MACHINE_TOOLS = [
|
|
299
|
+
{
|
|
300
|
+
name: 'salesforce_time_machine_query',
|
|
301
|
+
description: 'Query historical Salesforce data from backups using Time Machine functionality. Supports point-in-time queries, comparisons, and record history tracking.',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: {
|
|
305
|
+
operation: {
|
|
306
|
+
type: 'string',
|
|
307
|
+
enum: ['query_at_point_in_time', 'compare_over_time', 'get_record_history', 'list_backups'],
|
|
308
|
+
description: 'The Time Machine operation to perform'
|
|
309
|
+
},
|
|
310
|
+
targetDate: {
|
|
311
|
+
type: 'string',
|
|
312
|
+
description: 'Target date for point-in-time queries (ISO 8601 format)'
|
|
313
|
+
},
|
|
314
|
+
startDate: {
|
|
315
|
+
type: 'string',
|
|
316
|
+
description: 'Start date for comparison queries (ISO 8601 format)'
|
|
317
|
+
},
|
|
318
|
+
endDate: {
|
|
319
|
+
type: 'string',
|
|
320
|
+
description: 'End date for comparison queries (ISO 8601 format)'
|
|
321
|
+
},
|
|
322
|
+
objectType: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
description: 'Salesforce object type to query (e.g., Account, Contact, ContentVersion)'
|
|
325
|
+
},
|
|
326
|
+
recordId: {
|
|
327
|
+
type: 'string',
|
|
328
|
+
description: 'Specific record ID for history tracking'
|
|
329
|
+
},
|
|
330
|
+
filters: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
description: 'Optional filters to apply to the query (field-value pairs, supports wildcards with *)'
|
|
333
|
+
},
|
|
334
|
+
backupDirectory: {
|
|
335
|
+
type: 'string',
|
|
336
|
+
description: 'Path to backup directory (defaults to ./backups)'
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
required: ['operation']
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
export {
|
|
345
|
+
SalesforceTimeMachine,
|
|
346
|
+
TIME_MACHINE_TOOLS
|
|
347
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { hasInstallationDocumentation, getInstallationDocumentation } from './learn.js';
|
|
2
|
+
|
|
3
|
+
export const updateTool = {
|
|
4
|
+
name: "salesforce_update",
|
|
5
|
+
description: "Update an existing record in any Salesforce object. Requires the record ID and field values to update.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
sobject: {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "SObject API name (e.g., 'Contact', 'Account', 'CustomObject__c')"
|
|
12
|
+
},
|
|
13
|
+
id: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Salesforce record ID (15 or 18 character ID)"
|
|
16
|
+
},
|
|
17
|
+
data: {
|
|
18
|
+
type: "object",
|
|
19
|
+
description: "Field values to update. Only include fields you want to change (e.g., {'Email': 'newemail@example.com', 'Phone': '555-1234'})"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
required: ["sobject", "id", "data"]
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function executeUpdate(client, args) {
|
|
27
|
+
try {
|
|
28
|
+
const { sobject, id, data } = args;
|
|
29
|
+
|
|
30
|
+
// Check if installation has been learned for better context
|
|
31
|
+
const hasDocumentation = await hasInstallationDocumentation();
|
|
32
|
+
let contextMessage = '';
|
|
33
|
+
|
|
34
|
+
if (!hasDocumentation) {
|
|
35
|
+
contextMessage = `⚠️ **Tip:** Use \`salesforce_learn\` to analyze all available objects and fields.\n\n`;
|
|
36
|
+
} else {
|
|
37
|
+
// Provide context about the object if available
|
|
38
|
+
const documentation = await getInstallationDocumentation();
|
|
39
|
+
const objectInfo = documentation.objects[sobject];
|
|
40
|
+
if (objectInfo && !objectInfo.error) {
|
|
41
|
+
// Check for read-only fields in the provided data
|
|
42
|
+
const readOnlyFieldsInData = Object.keys(data).filter(fieldName => {
|
|
43
|
+
const field = objectInfo.fields[fieldName];
|
|
44
|
+
return field && field.writability && field.writability.read_only;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const nonUpdateableFields = Object.keys(data).filter(fieldName => {
|
|
48
|
+
const field = objectInfo.fields[fieldName];
|
|
49
|
+
return field && !field.updateable;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (readOnlyFieldsInData.length > 0) {
|
|
53
|
+
const readOnlyWarnings = readOnlyFieldsInData.map(fieldName => {
|
|
54
|
+
const field = objectInfo.fields[fieldName];
|
|
55
|
+
const reason = field.writability.system_managed ? 'System Managed' :
|
|
56
|
+
field.writability.formula ? 'Formula Field' :
|
|
57
|
+
field.writability.calculated ? 'Calculated Field' :
|
|
58
|
+
field.writability.rollup_summary ? 'Rollup Summary' :
|
|
59
|
+
field.writability.auto_number ? 'Auto Number' : 'Read-Only';
|
|
60
|
+
return `- ${field.label || fieldName} (${fieldName}) - ${reason}`;
|
|
61
|
+
}).join('\n');
|
|
62
|
+
|
|
63
|
+
contextMessage += `⚠️ **Warning: Read-only fields detected in update data:**\n${readOnlyWarnings}\n\n` +
|
|
64
|
+
`These fields cannot be updated and will be ignored by Salesforce.\n\n`;
|
|
65
|
+
} else if (nonUpdateableFields.length > 0) {
|
|
66
|
+
const nonUpdateableWarnings = nonUpdateableFields.map(fieldName => {
|
|
67
|
+
const field = objectInfo.fields[fieldName];
|
|
68
|
+
return `- ${field.label || fieldName} (${fieldName})`;
|
|
69
|
+
}).join('\n');
|
|
70
|
+
|
|
71
|
+
contextMessage += `⚠️ **Warning: Non-updateable fields detected:**\n${nonUpdateableWarnings}\n\n`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!sobject || typeof sobject !== 'string') {
|
|
77
|
+
throw new Error('sobject parameter is required and must be a string');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!id || typeof id !== 'string') {
|
|
81
|
+
throw new Error('id parameter is required and must be a string');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
85
|
+
throw new Error('data parameter is required and must be an object');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Validate data is not empty
|
|
89
|
+
if (Object.keys(data).length === 0) {
|
|
90
|
+
throw new Error('data object cannot be empty - specify at least one field to update');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate ID format (basic check)
|
|
94
|
+
if (id.length !== 15 && id.length !== 18) {
|
|
95
|
+
throw new Error('Invalid Salesforce ID format. ID must be 15 or 18 characters long.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Remove Id from data if accidentally included
|
|
99
|
+
const updateData = { ...data };
|
|
100
|
+
delete updateData.Id;
|
|
101
|
+
delete updateData.id;
|
|
102
|
+
|
|
103
|
+
const result = await client.update(sobject, id, updateData);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: `${contextMessage}✅ Successfully updated ${sobject} record!\n\n` +
|
|
110
|
+
`Record ID: ${result.id}\n` +
|
|
111
|
+
`Object Type: ${result.sobject}\n\n` +
|
|
112
|
+
`Updated fields:\n${JSON.stringify(updateData, null, 2)}\n\n` +
|
|
113
|
+
`You can view the updated record using:\n` +
|
|
114
|
+
`SELECT Id, * FROM ${sobject} WHERE Id = '${result.id}'`
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `❌ Failed to update ${args.sobject || 'record'}: ${error.message}\n\n` +
|
|
124
|
+
`Common issues:\n` +
|
|
125
|
+
`- Record not found (check the ID)\n` +
|
|
126
|
+
`- Invalid field names (use API names, not labels)\n` +
|
|
127
|
+
`- Read-only fields (some fields cannot be updated)\n` +
|
|
128
|
+
`- Invalid data types or formats\n` +
|
|
129
|
+
`- Insufficient permissions\n` +
|
|
130
|
+
`- Validation rule failures\n\n` +
|
|
131
|
+
`Record ID provided: ${args.id}\n` +
|
|
132
|
+
`Tip: Use salesforce_describe to check which fields are updateable`
|
|
133
|
+
}
|
|
134
|
+
],
|
|
135
|
+
isError: true
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.js — Tool handlers for the Delegate SF MCP
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { query, search, describe, createRecord, updateRecord } from './sf-client.js';
|
|
6
|
+
|
|
7
|
+
// ── 1. SOQL Query ─────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export async function handleQuery({ soql }) {
|
|
10
|
+
if (!soql) throw new Error('soql is required');
|
|
11
|
+
|
|
12
|
+
const records = await query(soql);
|
|
13
|
+
|
|
14
|
+
if (records.length === 0) return 'No records found.';
|
|
15
|
+
|
|
16
|
+
return JSON.stringify(records, null, 2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── 2. Get Record ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export async function handleGetRecord({ object_type, record_id, fields }) {
|
|
22
|
+
if (!object_type || !record_id) throw new Error('object_type and record_id are required');
|
|
23
|
+
|
|
24
|
+
const fieldList = fields?.length ? fields.join(',') : 'Id,Name';
|
|
25
|
+
const soql = `SELECT ${fieldList} FROM ${object_type} WHERE Id = '${record_id}' LIMIT 1`;
|
|
26
|
+
const records = await query(soql);
|
|
27
|
+
|
|
28
|
+
if (records.length === 0) return `No ${object_type} found with Id: ${record_id}`;
|
|
29
|
+
return JSON.stringify(records[0], null, 2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── 3. SOSL Search ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export async function handleSearch({ search_term, object_types, fields }) {
|
|
35
|
+
if (!search_term) throw new Error('search_term is required');
|
|
36
|
+
|
|
37
|
+
const objects = (object_types || ['Contact', 'Account', 'Lead', 'Opportunity'])
|
|
38
|
+
.map(obj => {
|
|
39
|
+
const f = fields || ['Id', 'Name'];
|
|
40
|
+
return `${obj}(${f.join(',')})`;
|
|
41
|
+
})
|
|
42
|
+
.join(', ');
|
|
43
|
+
|
|
44
|
+
const sosl = `FIND {${search_term.replace(/[?&|!{}[\]()^~*:\\"'+-]/g, '\\$&')}} IN ALL FIELDS RETURNING ${objects}`;
|
|
45
|
+
|
|
46
|
+
const records = await search(sosl);
|
|
47
|
+
|
|
48
|
+
if (records.length === 0) return `No records found matching: ${search_term}`;
|
|
49
|
+
return JSON.stringify(records, null, 2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── 4. Describe Object ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function handleDescribe({ object_type }) {
|
|
55
|
+
if (!object_type) throw new Error('object_type is required');
|
|
56
|
+
|
|
57
|
+
const meta = await describe(object_type);
|
|
58
|
+
|
|
59
|
+
// Return only useful subset — full describe is massive
|
|
60
|
+
const summary = {
|
|
61
|
+
name: meta.name,
|
|
62
|
+
label: meta.label,
|
|
63
|
+
fields: meta.fields.map(f => ({
|
|
64
|
+
name: f.name,
|
|
65
|
+
label: f.label,
|
|
66
|
+
type: f.type,
|
|
67
|
+
required: !f.nillable && !f.defaultedOnCreate,
|
|
68
|
+
updateable: f.updateable,
|
|
69
|
+
picklistValues: f.picklistValues?.filter(v => v.active).map(v => v.value) || [],
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return JSON.stringify(summary, null, 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 5. Create Record ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function handleCreate({ object_type, fields }) {
|
|
79
|
+
if (!object_type || !fields) throw new Error('object_type and fields are required');
|
|
80
|
+
|
|
81
|
+
const result = await createRecord(object_type, fields);
|
|
82
|
+
if (!result.success) throw new Error(result.errors?.map(e => e.message).join('; ') || 'Create failed');
|
|
83
|
+
|
|
84
|
+
return JSON.stringify({ success: true, id: result.id, object: object_type });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 6. Update Record ──────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function handleUpdate({ object_type, record_id, fields }) {
|
|
90
|
+
if (!object_type || !record_id || !fields) throw new Error('object_type, record_id, and fields are required');
|
|
91
|
+
|
|
92
|
+
const result = await updateRecord(object_type, record_id, fields);
|
|
93
|
+
if (!result.success) throw new Error(result.errors?.map(e => e.message).join('; ') || 'Update failed');
|
|
94
|
+
|
|
95
|
+
return JSON.stringify({ success: true, id: record_id, updated_fields: Object.keys(fields) });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 7. Verify Record Exists (Deduplication) ───────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export async function handleVerifyRecordExists({ object_type, name, email, phone, company }) {
|
|
101
|
+
if (!object_type) throw new Error('object_type is required');
|
|
102
|
+
|
|
103
|
+
const matches = [];
|
|
104
|
+
|
|
105
|
+
// Priority 1: email match
|
|
106
|
+
if (email) {
|
|
107
|
+
const soql = `SELECT Id, Name, Email, Account.Name FROM ${object_type} WHERE Email = '${email}' LIMIT 5`;
|
|
108
|
+
const records = await query(soql);
|
|
109
|
+
records.forEach(r => matches.push({ ...r, confidence: 95, match_reason: 'email' }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Priority 2: phone match
|
|
113
|
+
if (phone && matches.length === 0) {
|
|
114
|
+
const clean = phone.replace(/\D/g, '');
|
|
115
|
+
const soql = `SELECT Id, Name, Phone, Account.Name FROM ${object_type} WHERE Phone LIKE '%${clean.slice(-7)}%' LIMIT 5`;
|
|
116
|
+
const records = await query(soql);
|
|
117
|
+
records.forEach(r => matches.push({ ...r, confidence: 80, match_reason: 'phone' }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Priority 3: name + company
|
|
121
|
+
if (name && matches.length === 0) {
|
|
122
|
+
const nameParts = name.trim().split(/\s+/);
|
|
123
|
+
const lastName = nameParts[nameParts.length - 1];
|
|
124
|
+
|
|
125
|
+
let soql = `SELECT Id, Name, Email, Account.Name FROM ${object_type} WHERE LastName LIKE '%${lastName}%'`;
|
|
126
|
+
if (company) soql += ` AND Account.Name LIKE '%${company.split(' ')[0]}%'`;
|
|
127
|
+
soql += ' LIMIT 5';
|
|
128
|
+
|
|
129
|
+
const records = await query(soql);
|
|
130
|
+
records.forEach(r => matches.push({ ...r, confidence: 70, match_reason: 'name+company' }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (matches.length === 0) {
|
|
134
|
+
return JSON.stringify({ duplicate_found: false, message: 'No existing records found. Safe to create.' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return JSON.stringify({
|
|
138
|
+
duplicate_found: true,
|
|
139
|
+
matches,
|
|
140
|
+
message: `Found ${matches.length} potential match(es). Review before creating.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 8. Hygiene Score ──────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export async function handleGetHygieneScore({ account_id }) {
|
|
147
|
+
if (!account_id) throw new Error('account_id is required');
|
|
148
|
+
|
|
149
|
+
// Fetch account + opportunities + contacts + activities in parallel
|
|
150
|
+
const [opps, contacts, tasks, account] = await Promise.all([
|
|
151
|
+
query(`SELECT Id, Name, StageName, CloseDate, Amount, NextStep, ForecastCategoryName FROM Opportunity WHERE AccountId = '${account_id}' AND IsClosed = false`),
|
|
152
|
+
query(`SELECT Id, Name, Email, Phone, Title FROM Contact WHERE AccountId = '${account_id}'`),
|
|
153
|
+
query(`SELECT Id, Subject, Status, ActivityDate FROM Task WHERE AccountId = '${account_id}' AND IsClosed = false ORDER BY CreatedDate DESC LIMIT 10`),
|
|
154
|
+
query(`SELECT Id, Name, Type, Industry FROM Account WHERE Id = '${account_id}' LIMIT 1`),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const accountName = account[0]?.Name ?? account_id;
|
|
158
|
+
const now = new Date();
|
|
159
|
+
let score = 0;
|
|
160
|
+
const issues = [];
|
|
161
|
+
const strengths = [];
|
|
162
|
+
|
|
163
|
+
// 1. Required field completion (25 pts)
|
|
164
|
+
let fieldPoints = 0;
|
|
165
|
+
opps.forEach(opp => {
|
|
166
|
+
if (opp.Amount) fieldPoints += 3;
|
|
167
|
+
if (opp.NextStep) fieldPoints += 3;
|
|
168
|
+
if (opp.CloseDate) fieldPoints += 2;
|
|
169
|
+
});
|
|
170
|
+
const fieldScore = opps.length ? Math.min(25, Math.round(fieldPoints / opps.length * 5)) : 0;
|
|
171
|
+
score += fieldScore;
|
|
172
|
+
if (fieldScore < 15) issues.push('Missing required fields on open opportunities (Amount, NextStep, CloseDate)');
|
|
173
|
+
else strengths.push('Open opportunities have key fields populated');
|
|
174
|
+
|
|
175
|
+
// 2. Activity recency (25 pts)
|
|
176
|
+
const recentActivity = tasks.find(t => {
|
|
177
|
+
const d = new Date(t.ActivityDate || 0);
|
|
178
|
+
return (now - d) < 30 * 24 * 60 * 60 * 1000;
|
|
179
|
+
});
|
|
180
|
+
if (recentActivity) { score += 25; strengths.push('Activity logged within last 30 days'); }
|
|
181
|
+
else { issues.push('No activity logged in 30+ days'); }
|
|
182
|
+
|
|
183
|
+
// 3. Stage accuracy (20 pts) — flag stale close dates
|
|
184
|
+
const staleOpps = opps.filter(opp => opp.CloseDate && new Date(opp.CloseDate) < now);
|
|
185
|
+
if (staleOpps.length === 0) { score += 20; strengths.push('All close dates are in the future'); }
|
|
186
|
+
else { issues.push(`${staleOpps.length} opportunity(ies) have past close dates`); score += 5; }
|
|
187
|
+
|
|
188
|
+
// 4. Contact coverage (15 pts)
|
|
189
|
+
if (contacts.length === 0) issues.push('No contacts associated with this account');
|
|
190
|
+
else if (contacts.length === 1) { score += 8; issues.push('Single-threaded — only 1 contact'); }
|
|
191
|
+
else { score += 15; strengths.push(`${contacts.length} contacts on account`); }
|
|
192
|
+
|
|
193
|
+
// 5. Next step presence (15 pts)
|
|
194
|
+
const oppsWithNextStep = opps.filter(o => o.NextStep);
|
|
195
|
+
if (opps.length === 0) score += 15;
|
|
196
|
+
else if (oppsWithNextStep.length === opps.length) { score += 15; strengths.push('All open opportunities have next steps'); }
|
|
197
|
+
else { issues.push(`${opps.length - oppsWithNextStep.length} open opp(s) missing next step`); score += Math.round((oppsWithNextStep.length / opps.length) * 15); }
|
|
198
|
+
|
|
199
|
+
const grade = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 50 ? 'C' : score >= 35 ? 'D' : 'F';
|
|
200
|
+
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
account: accountName,
|
|
203
|
+
score,
|
|
204
|
+
grade,
|
|
205
|
+
open_opportunities: opps.length,
|
|
206
|
+
contacts: contacts.length,
|
|
207
|
+
open_tasks: tasks.length,
|
|
208
|
+
strengths,
|
|
209
|
+
issues,
|
|
210
|
+
recommendation: issues.length === 0
|
|
211
|
+
? 'Hygiene looks clean. No immediate action needed.'
|
|
212
|
+
: `Priority fixes: ${issues[0]}`,
|
|
213
|
+
}, null, 2);
|
|
214
|
+
}
|