@mindstone/mcp-server-salesforce 0.1.3 → 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.
@@ -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: false, idempotentHint: false, openWorldHint: true },
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', JSON.stringify(result.errors));
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
  }));
@@ -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: false, idempotentHint: false, openWorldHint: true },
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', JSON.stringify(result.errors));
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,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerReportTools(server: McpServer): void;
3
+ //# sourceMappingURL=reports.d.ts.map
@@ -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,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSearchTools(server: McpServer): void;
3
+ //# sourceMappingURL=search.d.ts.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
@@ -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: false, idempotentHint: false, openWorldHint: true },
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', JSON.stringify(result.errors));
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
  }));
@@ -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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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[];