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.
Files changed (44) hide show
  1. package/.eslintrc.json +20 -0
  2. package/LICENSE +24 -0
  3. package/README.md +76 -0
  4. package/auth.js +148 -0
  5. package/bin/config-helper.js +51 -0
  6. package/bin/mcp-salesforce.js +12 -0
  7. package/bin/setup.js +266 -0
  8. package/bin/status.js +134 -0
  9. package/docs/README.md +52 -0
  10. package/docs/step1.png +0 -0
  11. package/docs/step2.png +0 -0
  12. package/docs/step3.png +0 -0
  13. package/docs/step4.png +0 -0
  14. package/examples/README.md +35 -0
  15. package/package.json +16 -0
  16. package/scripts/README.md +30 -0
  17. package/src/auth/file-storage.js +447 -0
  18. package/src/auth/oauth.js +417 -0
  19. package/src/auth/token-manager.js +207 -0
  20. package/src/backup/manager.js +949 -0
  21. package/src/index.js +168 -0
  22. package/src/salesforce/client.js +388 -0
  23. package/src/sf-client.js +79 -0
  24. package/src/tools/auth.js +190 -0
  25. package/src/tools/backup.js +486 -0
  26. package/src/tools/create.js +109 -0
  27. package/src/tools/delegate-hygiene.js +268 -0
  28. package/src/tools/delegate-validate.js +212 -0
  29. package/src/tools/delegate-verify.js +143 -0
  30. package/src/tools/delete.js +72 -0
  31. package/src/tools/describe.js +132 -0
  32. package/src/tools/installation-info.js +656 -0
  33. package/src/tools/learn-context.js +1077 -0
  34. package/src/tools/learn.js +351 -0
  35. package/src/tools/query.js +82 -0
  36. package/src/tools/repair-credentials.js +77 -0
  37. package/src/tools/setup.js +120 -0
  38. package/src/tools/time_machine.js +347 -0
  39. package/src/tools/update.js +138 -0
  40. package/src/tools.js +214 -0
  41. package/src/utils/cache.js +120 -0
  42. package/src/utils/debug.js +52 -0
  43. package/src/utils/logger.js +19 -0
  44. 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
+ }