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,109 @@
|
|
|
1
|
+
import { hasInstallationDocumentation, getInstallationDocumentation } from './learn.js';
|
|
2
|
+
|
|
3
|
+
export const createTool = {
|
|
4
|
+
name: "salesforce_create",
|
|
5
|
+
description: "Create a new record in any Salesforce object. Automatically handles required fields validation.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
sobject: {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "SObject API name (e.g., 'Contact', 'Account', 'CustomObject__c'). Use exact API names."
|
|
12
|
+
},
|
|
13
|
+
data: {
|
|
14
|
+
type: "object",
|
|
15
|
+
description: "Field values for the new record. Use API field names as keys (e.g., {'FirstName': 'John', 'LastName': 'Doe', 'Email': 'john@example.com'})"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
required: ["sobject", "data"]
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function executeCreate(client, args) {
|
|
23
|
+
try {
|
|
24
|
+
const { sobject, data } = args;
|
|
25
|
+
|
|
26
|
+
// Check if installation has been learned and provide helpful context
|
|
27
|
+
const hasDocumentation = await hasInstallationDocumentation();
|
|
28
|
+
let contextMessage = '';
|
|
29
|
+
|
|
30
|
+
if (!hasDocumentation) {
|
|
31
|
+
contextMessage = `β οΈ **Tipp:** Die Salesforce-Installation wurde noch nicht analysiert. Verwende \`salesforce_learn\` um alle verfΓΌgbaren Objekte und Felder kennenzulernen.\n\n`;
|
|
32
|
+
} else {
|
|
33
|
+
// Provide context about the object if available
|
|
34
|
+
const documentation = await getInstallationDocumentation();
|
|
35
|
+
const objectInfo = documentation.objects[sobject];
|
|
36
|
+
if (objectInfo && !objectInfo.error) {
|
|
37
|
+
const requiredFields = Object.entries(objectInfo.fields)
|
|
38
|
+
.filter(([name, field]) => field.required)
|
|
39
|
+
.map(([name, field]) => `${field.label} (${name})`);
|
|
40
|
+
|
|
41
|
+
if (requiredFields.length > 0) {
|
|
42
|
+
contextMessage = `π‘ **Required fields for ${objectInfo.basic_info.label}:** ${requiredFields.join(', ')}\n\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for read-only fields in the provided data
|
|
46
|
+
const readOnlyFieldsInData = Object.keys(data).filter(fieldName => {
|
|
47
|
+
const field = objectInfo.fields[fieldName];
|
|
48
|
+
return field && field.writability && field.writability.read_only;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (readOnlyFieldsInData.length > 0) {
|
|
52
|
+
const readOnlyWarnings = readOnlyFieldsInData.map(fieldName => {
|
|
53
|
+
const field = objectInfo.fields[fieldName];
|
|
54
|
+
return `- ${field.label || fieldName} (${fieldName})`;
|
|
55
|
+
}).join('\n');
|
|
56
|
+
|
|
57
|
+
contextMessage += `β οΈ **Warning: Read-only fields detected in data:**\n${readOnlyWarnings}\n\n` +
|
|
58
|
+
`These fields cannot be set during record creation and will be ignored by Salesforce.\n\n`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!sobject || typeof sobject !== 'string') {
|
|
64
|
+
throw new Error('sobject parameter is required and must be a string');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
68
|
+
throw new Error('data parameter is required and must be an object');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate data is not empty
|
|
72
|
+
if (Object.keys(data).length === 0) {
|
|
73
|
+
throw new Error('data object cannot be empty');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await client.create(sobject, data);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: `${contextMessage}β
Successfully created ${sobject} record!\n\n` +
|
|
83
|
+
`Record ID: ${result.id}\n` +
|
|
84
|
+
`Object Type: ${result.sobject}\n\n` +
|
|
85
|
+
`Created with data:\n${JSON.stringify(data, null, 2)}\n\n` +
|
|
86
|
+
`You can view this record in Salesforce or query it using:\n` +
|
|
87
|
+
`SELECT Id, * FROM ${sobject} WHERE Id = '${result.id}'`
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "text",
|
|
96
|
+
text: `β Failed to create ${args.sobject || 'record'}: ${error.message}\n\n` +
|
|
97
|
+
`Common issues:\n` +
|
|
98
|
+
`- Required fields missing (use salesforce_describe to see required fields)\n` +
|
|
99
|
+
`- Invalid field names (use API names, not labels)\n` +
|
|
100
|
+
`- Invalid data types or formats\n` +
|
|
101
|
+
`- Insufficient permissions\n` +
|
|
102
|
+
`- Validation rule failures\n\n` +
|
|
103
|
+
`Tip: Use salesforce_describe to get field information for ${args.sobject || 'the object'}`
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
isError: true
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delegate_get_hygiene_score β composite health score per account or rep
|
|
3
|
+
* delegate_bulk_update β batch approved writes via Composite API (up to 25 at once)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// βββ delegate_get_hygiene_score βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
7
|
+
|
|
8
|
+
export const getHygieneScoreTool = {
|
|
9
|
+
name: 'delegate_get_hygiene_score',
|
|
10
|
+
description:
|
|
11
|
+
'Delegate: Generate a composite Salesforce hygiene score for an Account or a rep (by owner). ' +
|
|
12
|
+
'Weighted across: required field completion, activity recency, stage accuracy, contact role coverage, next step presence. ' +
|
|
13
|
+
'Use this to surface which accounts need attention and to track data quality over time.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
accountId: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Score hygiene for a specific Account (and its open opps)',
|
|
20
|
+
},
|
|
21
|
+
accountName: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Account name (partial match, used if accountId not known)',
|
|
24
|
+
},
|
|
25
|
+
ownerId: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: "Score hygiene for all open opportunities owned by this user (rep-level view)",
|
|
28
|
+
},
|
|
29
|
+
ownerName: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Owner name (partial match, used if ownerId not known)',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function executeGetHygieneScore(client, args) {
|
|
38
|
+
const { accountId, accountName, ownerId, ownerName } = args;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// ββ Resolve IDs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
42
|
+
let resolvedAccountId = accountId;
|
|
43
|
+
if (!resolvedAccountId && accountName) {
|
|
44
|
+
const r = await client.query(
|
|
45
|
+
`SELECT Id FROM Account WHERE Name LIKE '%${accountName.replace(/'/g, "\\'")}%' LIMIT 1`
|
|
46
|
+
);
|
|
47
|
+
if (r.records.length) resolvedAccountId = r.records[0].Id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let resolvedOwnerId = ownerId;
|
|
51
|
+
if (!resolvedOwnerId && ownerName) {
|
|
52
|
+
const r = await client.query(
|
|
53
|
+
`SELECT Id FROM User WHERE Name LIKE '%${ownerName.replace(/'/g, "\\'")}%' AND IsActive = true LIMIT 1`
|
|
54
|
+
);
|
|
55
|
+
if (r.records.length) resolvedOwnerId = r.records[0].Id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!resolvedAccountId && !resolvedOwnerId) {
|
|
59
|
+
return err('Provide accountId, accountName, ownerId, or ownerName');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ββ Fetch opportunities βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
63
|
+
const whereClause = resolvedAccountId
|
|
64
|
+
? `AccountId = '${resolvedAccountId}'`
|
|
65
|
+
: `OwnerId = '${resolvedOwnerId}'`;
|
|
66
|
+
|
|
67
|
+
const opps = await client.query(
|
|
68
|
+
`SELECT Id, Name, StageName, CloseDate, Amount, NextStep, LastActivityDate,
|
|
69
|
+
OwnerId, Owner.Name, AccountId, Account.Name, Probability
|
|
70
|
+
FROM Opportunity
|
|
71
|
+
WHERE IsClosed = false AND ${whereClause}
|
|
72
|
+
LIMIT 50`
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!opps.records.length) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text', text: 'β οΈ No open opportunities found for the given account/owner.' }],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ββ Fetch contact role coverage βββββββββββββββββββββββββββββββββββββββββββ
|
|
82
|
+
const oppIds = opps.records.map(o => `'${o.Id}'`).join(',');
|
|
83
|
+
const contactRoles = await client.query(
|
|
84
|
+
`SELECT OpportunityId, COUNT(Id) RoleCount
|
|
85
|
+
FROM OpportunityContactRole
|
|
86
|
+
WHERE OpportunityId IN (${oppIds})
|
|
87
|
+
GROUP BY OpportunityId`
|
|
88
|
+
);
|
|
89
|
+
const roleMap = Object.fromEntries(contactRoles.records.map(r => [r.OpportunityId, r.RoleCount]));
|
|
90
|
+
|
|
91
|
+
// ββ Fetch open tasks (next step proxy) ββββββββββββββββββββββββββββββββββββ
|
|
92
|
+
const openTasks = await client.query(
|
|
93
|
+
`SELECT WhatId FROM Task WHERE WhatId IN (${oppIds}) AND IsClosed = false`
|
|
94
|
+
);
|
|
95
|
+
const taskSet = new Set(openTasks.records.map(t => t.WhatId));
|
|
96
|
+
|
|
97
|
+
// ββ Score each opp ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
98
|
+
const now = new Date();
|
|
99
|
+
const scored = opps.records.map(opp => {
|
|
100
|
+
const issues = [];
|
|
101
|
+
let score = 100;
|
|
102
|
+
|
|
103
|
+
// Required fields (β15 each)
|
|
104
|
+
if (!opp.Amount) { issues.push('Missing Amount'); score -= 15; }
|
|
105
|
+
if (!opp.CloseDate) { issues.push('Missing Close Date'); score -= 15; }
|
|
106
|
+
|
|
107
|
+
// Stale close date (β20)
|
|
108
|
+
if (opp.CloseDate && new Date(opp.CloseDate) < now) {
|
|
109
|
+
issues.push('Close date in the past'); score -= 20;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// No activity in 30+ days (β15)
|
|
113
|
+
const daysSinceActivity = opp.LastActivityDate
|
|
114
|
+
? Math.floor((now - new Date(opp.LastActivityDate)) / 86_400_000)
|
|
115
|
+
: 999;
|
|
116
|
+
if (daysSinceActivity > 30) {
|
|
117
|
+
issues.push(`No activity in ${daysSinceActivity === 999 ? 'ever' : daysSinceActivity + ' days'}`);
|
|
118
|
+
score -= 15;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// No contact roles (β20)
|
|
122
|
+
if (!roleMap[opp.Id]) { issues.push('No contact roles'); score -= 20; }
|
|
123
|
+
|
|
124
|
+
// No open next step / task (β15)
|
|
125
|
+
if (!taskSet.has(opp.Id) && !opp.NextStep) {
|
|
126
|
+
issues.push('No next step or open task'); score -= 15;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
id: opp.Id,
|
|
131
|
+
name: opp.Name,
|
|
132
|
+
account: opp['Account.Name'],
|
|
133
|
+
owner: opp['Owner.Name'],
|
|
134
|
+
stage: opp.StageName,
|
|
135
|
+
closeDate: opp.CloseDate,
|
|
136
|
+
amount: opp.Amount,
|
|
137
|
+
score: Math.max(0, score),
|
|
138
|
+
grade: score >= 80 ? 'π’ Good' : score >= 60 ? 'π‘ Fair' : 'π΄ Poor',
|
|
139
|
+
issues,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const avg = Math.round(scored.reduce((s, o) => s + o.score, 0) / scored.length);
|
|
144
|
+
const label = resolvedAccountId
|
|
145
|
+
? `Account: ${scored[0]?.account || resolvedAccountId}`
|
|
146
|
+
: `Owner: ${opps.records[0]?.['Owner.Name'] || resolvedOwnerId}`;
|
|
147
|
+
|
|
148
|
+
const poor = scored.filter(o => o.score < 60).length;
|
|
149
|
+
const fair = scored.filter(o => o.score >= 60 && o.score < 80).length;
|
|
150
|
+
const good = scored.filter(o => o.score >= 80).length;
|
|
151
|
+
|
|
152
|
+
const detail = scored
|
|
153
|
+
.sort((a, b) => a.score - b.score)
|
|
154
|
+
.map(o =>
|
|
155
|
+
`${o.grade} [${o.score}] ${o.name}\n` +
|
|
156
|
+
(o.issues.length ? ` Issues: ${o.issues.join(' Β· ')}` : ' No issues')
|
|
157
|
+
)
|
|
158
|
+
.join('\n\n');
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'text',
|
|
164
|
+
text:
|
|
165
|
+
`π **Hygiene Score β ${label}**\n\n` +
|
|
166
|
+
`Overall: **${avg}/100** | π’ ${good} Good π‘ ${fair} Fair π΄ ${poor} Poor\n\n` +
|
|
167
|
+
`**Opportunities (${scored.length}):**\n\n${detail}`,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
_delegateResult: { averageScore: avg, opportunities: scored },
|
|
171
|
+
};
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return err(`Hygiene score failed: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// βββ delegate_bulk_update βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
178
|
+
|
|
179
|
+
export const bulkUpdateTool = {
|
|
180
|
+
name: 'delegate_bulk_update',
|
|
181
|
+
description:
|
|
182
|
+
'Delegate: Execute multiple pre-approved Salesforce updates in a single batched operation. ' +
|
|
183
|
+
'Only call this AFTER the user has explicitly approved each update. ' +
|
|
184
|
+
'Batches up to 25 writes efficiently. Returns per-record results β partial success is handled gracefully.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
updates: {
|
|
189
|
+
type: 'array',
|
|
190
|
+
description: 'Array of approved updates to execute',
|
|
191
|
+
items: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
objectType: { type: 'string', description: "e.g. 'Opportunity'" },
|
|
195
|
+
id: { type: 'string', description: 'Salesforce record ID' },
|
|
196
|
+
fields: { type: 'object', description: 'Fields to update' },
|
|
197
|
+
description: { type: 'string', description: 'Human-readable description for the confirmation message' },
|
|
198
|
+
},
|
|
199
|
+
required: ['objectType', 'id', 'fields'],
|
|
200
|
+
},
|
|
201
|
+
maxItems: 25,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
required: ['updates'],
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export async function executeBulkUpdate(client, args) {
|
|
209
|
+
const { updates } = args;
|
|
210
|
+
|
|
211
|
+
if (!updates?.length) {
|
|
212
|
+
return err('No updates provided');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const results = [];
|
|
216
|
+
let successCount = 0;
|
|
217
|
+
let failCount = 0;
|
|
218
|
+
|
|
219
|
+
// Execute sequentially β jsforce v3 doesn't expose Composite REST directly,
|
|
220
|
+
// so we batch with Promise.allSettled for parallelism within the rate limit
|
|
221
|
+
const settled = await Promise.allSettled(
|
|
222
|
+
updates.map(u => client.update(u.objectType, u.id, u.fields))
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < updates.length; i++) {
|
|
226
|
+
const u = updates[i];
|
|
227
|
+
const outcome = settled[i];
|
|
228
|
+
|
|
229
|
+
if (outcome.status === 'fulfilled') {
|
|
230
|
+
successCount++;
|
|
231
|
+
results.push({
|
|
232
|
+
status: 'β
',
|
|
233
|
+
objectType: u.objectType,
|
|
234
|
+
id: u.id,
|
|
235
|
+
description: u.description || Object.keys(u.fields).join(', '),
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
failCount++;
|
|
239
|
+
results.push({
|
|
240
|
+
status: 'β',
|
|
241
|
+
objectType: u.objectType,
|
|
242
|
+
id: u.id,
|
|
243
|
+
description: u.description || Object.keys(u.fields).join(', '),
|
|
244
|
+
error: outcome.reason?.message || 'Unknown error',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const lines = results.map(
|
|
250
|
+
r => `${r.status} ${r.objectType} ${r.id} β ${r.description}${r.error ? `\n Error: ${r.error}` : ''}`
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
content: [
|
|
255
|
+
{
|
|
256
|
+
type: 'text',
|
|
257
|
+
text:
|
|
258
|
+
`**Bulk update complete: ${successCount} succeeded, ${failCount} failed**\n\n` +
|
|
259
|
+
lines.join('\n'),
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
_delegateResult: { successCount, failCount, results },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function err(msg) {
|
|
267
|
+
return { content: [{ type: 'text', text: `β ${msg}` }], isError: true };
|
|
268
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delegate_validate_update β pre-flight validation before any write surfaces to user
|
|
3
|
+
* delegate_get_metadata_modified_dates β cheap timestamp check, the #1 governor limit optimization
|
|
4
|
+
*
|
|
5
|
+
* These two together guarantee zero failed writes after approval.
|
|
6
|
+
* The modified date check is the single most important optimization in the entire MCP.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// βββ delegate_get_metadata_modified_dates βββββββββββββββββββββββββββββββββββββ
|
|
10
|
+
|
|
11
|
+
export const getMetadataModifiedDatesTool = {
|
|
12
|
+
name: 'delegate_get_metadata_modified_dates',
|
|
13
|
+
description:
|
|
14
|
+
'Delegate: Cheap timestamp check on Salesforce field metadata. ' +
|
|
15
|
+
'Call this BEFORE pulling full schema. If nothing changed since last_check, skip the expensive metadata read entirely. ' +
|
|
16
|
+
'This is the #1 governor limit optimization β saves 80% of metadata API calls.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
objectNames: {
|
|
21
|
+
type: 'array',
|
|
22
|
+
items: { type: 'string' },
|
|
23
|
+
description: "Salesforce object API names to check (e.g. ['Opportunity','Contact','Account','Task'])",
|
|
24
|
+
},
|
|
25
|
+
lastCheckTimestamp: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description:
|
|
28
|
+
'ISO datetime of last schema check. Objects with LastModifiedDate > this will be flagged as stale.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ['objectNames'],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function executeGetMetadataModifiedDates(client, args) {
|
|
36
|
+
const { objectNames, lastCheckTimestamp } = args;
|
|
37
|
+
|
|
38
|
+
// Default: yesterday (forces a fresh check if no checkpoint provided)
|
|
39
|
+
const since = lastCheckTimestamp
|
|
40
|
+
? new Date(lastCheckTimestamp).toISOString()
|
|
41
|
+
: new Date(Date.now() - 86_400_000).toISOString();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const nameList = objectNames.map(n => `'${n}'`).join(',');
|
|
45
|
+
|
|
46
|
+
// This is the critical query from the spec β cheap, targeted, saves everything else
|
|
47
|
+
const result = await client.query(
|
|
48
|
+
`SELECT EntityDefinition.QualifiedApiName, MAX(LastModifiedDate) MaxMod
|
|
49
|
+
FROM FieldDefinition
|
|
50
|
+
WHERE EntityDefinition.QualifiedApiName IN (${nameList})
|
|
51
|
+
GROUP BY EntityDefinition.QualifiedApiName`
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const stale = [];
|
|
55
|
+
const fresh = [];
|
|
56
|
+
|
|
57
|
+
for (const row of result.records) {
|
|
58
|
+
const objName = row['EntityDefinition.QualifiedApiName'];
|
|
59
|
+
const serverMod = new Date(row.MaxMod);
|
|
60
|
+
const sinceDate = new Date(since);
|
|
61
|
+
|
|
62
|
+
if (serverMod > sinceDate) {
|
|
63
|
+
stale.push({ object: objName, lastModified: row.MaxMod });
|
|
64
|
+
} else {
|
|
65
|
+
fresh.push(objName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cacheValid = stale.length === 0;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: 'text',
|
|
75
|
+
text: cacheValid
|
|
76
|
+
? `β
Schema cache valid for: ${fresh.join(', ')}. Skip full metadata pull.`
|
|
77
|
+
: `β οΈ Schema changed for: ${stale.map(s => s.object).join(', ')}. ` +
|
|
78
|
+
`Pull full metadata for these objects only.\n\n${JSON.stringify(stale, null, 2)}`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
_delegateResult: {
|
|
82
|
+
cacheValid,
|
|
83
|
+
staleObjects: stale,
|
|
84
|
+
freshObjects: fresh,
|
|
85
|
+
checkedSince: since,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text', text: `β Metadata timestamp check failed: ${err.message}` }],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// βββ delegate_validate_update βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
97
|
+
|
|
98
|
+
export const validateUpdateTool = {
|
|
99
|
+
name: 'delegate_validate_update',
|
|
100
|
+
description:
|
|
101
|
+
'Delegate: Pre-flight validation β checks a proposed update against live Salesforce org metadata ' +
|
|
102
|
+
'BEFORE surfacing it to the user. Validates required fields, picklist values, field types, and updateability. ' +
|
|
103
|
+
'A suggestion that fails this check never reaches the approval queue. Zero failed writes after approval.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
objectName: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: "Salesforce object API name (e.g. 'Opportunity')",
|
|
110
|
+
},
|
|
111
|
+
recordId: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: 'Record ID being updated (used to fetch current state for context)',
|
|
114
|
+
},
|
|
115
|
+
fields: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
description: 'Proposed field updates β { fieldApiName: value }',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ['objectName', 'fields'],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export async function executeValidateUpdate(client, args) {
|
|
125
|
+
const { objectName, recordId, fields } = args;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// Pull live metadata for the object
|
|
129
|
+
const describeResult = await client.describe(objectName);
|
|
130
|
+
|
|
131
|
+
const fieldMap = Object.fromEntries(
|
|
132
|
+
describeResult.fields.map(f => [f.name.toLowerCase(), f])
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const errors = [];
|
|
136
|
+
const warnings = [];
|
|
137
|
+
const validated = [];
|
|
138
|
+
|
|
139
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
140
|
+
const meta = fieldMap[fieldName.toLowerCase()];
|
|
141
|
+
|
|
142
|
+
if (!meta) {
|
|
143
|
+
errors.push(`Field '${fieldName}' does not exist on ${objectName}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!meta.updateable) {
|
|
148
|
+
errors.push(
|
|
149
|
+
`Field '${fieldName}' (${meta.label}) is not updateable β ` +
|
|
150
|
+
(meta.calculated ? 'formula field' : meta.autoNumber ? 'auto-number' : 'system managed')
|
|
151
|
+
);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (meta.type === 'picklist' && meta.picklistValues?.length > 0) {
|
|
156
|
+
const valid = meta.picklistValues.filter(p => p.active).map(p => p.value);
|
|
157
|
+
if (!valid.includes(value)) {
|
|
158
|
+
errors.push(
|
|
159
|
+
`'${value}' is not a valid picklist value for ${fieldName}. ` +
|
|
160
|
+
`Valid options: ${valid.join(', ')}`
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (meta.type === 'date' || meta.type === 'datetime') {
|
|
167
|
+
if (value && isNaN(Date.parse(value))) {
|
|
168
|
+
errors.push(`'${value}' is not a valid date for ${fieldName}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (meta.type === 'currency' || meta.type === 'double' || meta.type === 'integer') {
|
|
174
|
+
if (value !== null && value !== undefined && isNaN(Number(value))) {
|
|
175
|
+
errors.push(`'${value}' is not a valid number for ${fieldName} (${meta.type})`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (meta.nillable === false && (value === null || value === undefined || value === '')) {
|
|
181
|
+
warnings.push(`Field '${fieldName}' is required and cannot be blank`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
validated.push({ field: fieldName, label: meta.label, type: meta.type, value });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const isValid = errors.length === 0;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text',
|
|
193
|
+
text: isValid
|
|
194
|
+
? `β
Validation passed for ${objectName}${recordId ? ` (${recordId})` : ''}.\n\n` +
|
|
195
|
+
`Validated fields:\n${validated.map(v => ` β’ ${v.label} (${v.field}): ${v.value}`).join('\n')}` +
|
|
196
|
+
(warnings.length ? `\n\nβ οΈ Warnings:\n${warnings.map(w => ` β’ ${w}`).join('\n')}` : '')
|
|
197
|
+
: `β Validation failed β ${errors.length} error(s). This update will NOT be surfaced to the user.\n\n` +
|
|
198
|
+
`Errors:\n${errors.map(e => ` β’ ${e}`).join('\n')}` +
|
|
199
|
+
(warnings.length ? `\n\nWarnings:\n${warnings.map(w => ` β’ ${w}`).join('\n')}` : ''),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
_delegateResult: { isValid, errors, warnings, validated },
|
|
203
|
+
isError: !isValid,
|
|
204
|
+
};
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: 'text', text: `β Validation error: ${err.message}` }],
|
|
208
|
+
_delegateResult: { isValid: false, errors: [err.message], warnings: [], validated: [] },
|
|
209
|
+
isError: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|