@mindstone/mcp-server-salesforce 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Salesforce CRM MCP server — accounts, contacts, opportunities, leads, tasks, u
9
9
 
10
10
  ## Status
11
11
 
12
- - **Version:** [0.2.1](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-salesforce)
12
+ - **Version:** [0.2.3](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-salesforce)
13
13
  - **Auth:** OAuth (local 127.0.0.1 callback) or static access token ([`SALESFORCE_CLIENT_SECRET`](./server.json), [`SALESFORCE_ACCESS_TOKEN`](./server.json))
14
14
  - **Tools:** [37](./src/tools/) (accounts, contacts, opportunities, leads, tasks, cases, events, search, notes, campaigns, reports, query)
15
15
  - **Surface:** cloud-api
package/dist/bridge.d.ts CHANGED
@@ -4,8 +4,13 @@
4
4
  export declare const BRIDGE_STATE_PATH: string;
5
5
  /**
6
6
  * Send a request to the host app bridge.
7
+ *
8
+ * `timeoutMs` defaults to the ordinary per-call budget (REQUEST_TIMEOUT_MS);
9
+ * callers that trigger long host-side flows (e.g. interactive OAuth) can opt
10
+ * into a longer one. The abort itself is never removed — only widened — so a
11
+ * dead bridge still fails within the chosen bound.
7
12
  */
8
- export declare const bridgeRequest: (urlPath: string, body?: Record<string, unknown>) => Promise<{
13
+ export declare const bridgeRequest: (urlPath: string, body?: Record<string, unknown>, timeoutMs?: number) => Promise<{
9
14
  success: boolean;
10
15
  username?: string;
11
16
  warning?: string;
package/dist/bridge.js CHANGED
@@ -17,15 +17,20 @@ const loadBridgeState = () => {
17
17
  };
18
18
  /**
19
19
  * Send a request to the host app bridge.
20
+ *
21
+ * `timeoutMs` defaults to the ordinary per-call budget (REQUEST_TIMEOUT_MS);
22
+ * callers that trigger long host-side flows (e.g. interactive OAuth) can opt
23
+ * into a longer one. The abort itself is never removed — only widened — so a
24
+ * dead bridge still fails within the chosen bound.
20
25
  */
21
- export const bridgeRequest = async (urlPath, body = {}) => {
26
+ export const bridgeRequest = async (urlPath, body = {}, timeoutMs = REQUEST_TIMEOUT_MS) => {
22
27
  const bridge = loadBridgeState();
23
28
  if (!bridge) {
24
29
  return { success: false, error: 'Bridge not available' };
25
30
  }
26
31
  const response = await fetch(`http://127.0.0.1:${bridge.port}${urlPath}`, {
27
32
  method: 'POST',
28
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
33
+ signal: AbortSignal.timeout(timeoutMs),
29
34
  headers: {
30
35
  'Content-Type': 'application/json',
31
36
  Authorization: `Bearer ${bridge.token}`,
@@ -1,12 +1,14 @@
1
1
  import { z } from 'zod';
2
- import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
2
+ import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords, pickArg } from '../utils.js';
3
3
  import { withConnection } from '../client.js';
4
4
  export function registerAccountTools(server) {
5
5
  server.registerTool('salesforce_get_accounts', {
6
6
  description: `Get CRM accounts. Filters: name_contains, industry, account_type. Returns: Id, Name, Industry, Type, Phone, Website, Description. Max 200 records (default: 50).`,
7
7
  inputSchema: z.object({
8
8
  limit: z.number().int().min(1).max(200).optional().describe('Max results 1-200 (default: 50)'),
9
+ max_results: z.number().int().min(1).max(200).optional().describe('Alias of limit.'),
9
10
  name_contains: z.string().optional().describe('Filter by name (case-insensitive)'),
11
+ query: z.string().optional().describe('Alias of name_contains.'),
10
12
  industry: z.string().optional().describe('Filter by industry'),
11
13
  account_type: z.string().optional().describe('Filter by type (Customer, Partner, Competitor)'),
12
14
  fields: z.array(z.string()).optional().describe('Custom fields (must be valid API names)'),
@@ -18,15 +20,16 @@ export function registerAccountTools(server) {
18
20
  const fields = validateFields(args.fields || [], defaultFields);
19
21
  let query = `SELECT ${fields.join(', ')} FROM Account`;
20
22
  const conditions = [];
21
- if (args.name_contains)
22
- conditions.push(`Name LIKE '%${escapeSOQLLike(args.name_contains)}%'`);
23
+ const nameContains = pickArg({ name_contains: args.name_contains, query: args.query });
24
+ if (nameContains)
25
+ conditions.push(`Name LIKE '%${escapeSOQLLike(nameContains)}%'`);
23
26
  if (args.industry)
24
27
  conditions.push(`Industry = '${escapeSOQL(args.industry)}'`);
25
28
  if (args.account_type)
26
29
  conditions.push(`Type = '${escapeSOQL(args.account_type)}'`);
27
30
  if (conditions.length > 0)
28
31
  query += ` WHERE ${conditions.join(' AND ')}`;
29
- const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
32
+ const limit = Math.min(Math.max(1, pickArg({ limit: args.limit, max_results: args.max_results }) ?? 50), 200);
30
33
  query += ` LIMIT ${limit}`;
31
34
  const result = await conn.query(query);
32
35
  return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:get_accounts:records'), totalSize: result.totalSize, done: result.done });
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { withErrorHandling, sanitizeExternalData } from '../utils.js';
4
4
  import { getAuthMode, loadToken, startStandaloneOAuth } from '../auth.js';
5
5
  import { bridgeRequest } from '../bridge.js';
6
+ import { BRIDGE_OAUTH_TIMEOUT_MS } from '../types.js';
6
7
  import { listConnectedAccounts, removeAccount } from '../client.js';
7
8
  // Disconnect needs a raw handle the model can copy verbatim into
8
9
  // salesforce_disconnect_account, but the storage id is username-derived
@@ -50,7 +51,11 @@ After connecting, verify with salesforce_list_connected_accounts.`,
50
51
  });
51
52
  }
52
53
  if (mode === 'bridge') {
53
- const result = await bridgeRequest(process.env.MCP_BRIDGE_CONFIGURE_ENDPOINT || '/mcp/configure', {});
54
+ // The host holds this response open until the user completes the
55
+ // interactive OAuth flow in their browser (up to 5 minutes), so this
56
+ // call needs the long budget — the default 30s abort would kill the
57
+ // connect mid-flow while the human is still signing in.
58
+ const result = await bridgeRequest(process.env.MCP_BRIDGE_CONFIGURE_ENDPOINT || '/mcp/configure', {}, BRIDGE_OAUTH_TIMEOUT_MS);
54
59
  if (result.success) {
55
60
  // The bridge username is host/org-authored external text: envelope
56
61
  // it before it reaches model-visible output (AGENTS.md invariant #6).
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
2
+ import { withErrorHandling, escapeSOQL, escapeSOQLLike, validateFields, validateAndMergeCustomFields, formatSOQLDate, checkSaveResult, formatVendorErrors, sanitizeRecords, pickArg } from '../utils.js';
3
3
  import { withConnection } from '../client.js';
4
4
  import { ConnectorError } from '../types.js';
5
5
  export function registerOpportunityTools(server) {
@@ -9,7 +9,9 @@ export function registerOpportunityTools(server) {
9
9
  limit: z.number().int().min(1).max(200).optional().describe('Max results 1-200 (default: 50)'),
10
10
  name_contains: z.string().optional().describe('Filter by name'),
11
11
  stage: z.string().optional().describe('Filter by stage'),
12
+ status: z.string().optional().describe('Alias of stage.'),
12
13
  related_account_id: z.string().optional().describe('Filter by Account ID'),
14
+ account_id: z.string().optional().describe('Alias of related_account_id.'),
13
15
  close_date_from: z.string().optional().describe('Closing on/after date (YYYY-MM-DD)'),
14
16
  close_date_to: z.string().optional().describe('Closing on/before date (YYYY-MM-DD)'),
15
17
  fields: z.array(z.string()).optional().describe('Custom fields'),
@@ -23,10 +25,12 @@ export function registerOpportunityTools(server) {
23
25
  const conditions = [];
24
26
  if (args.name_contains)
25
27
  conditions.push(`Name LIKE '%${escapeSOQLLike(args.name_contains)}%'`);
26
- if (args.stage)
27
- conditions.push(`StageName = '${escapeSOQL(args.stage)}'`);
28
- if (args.related_account_id)
29
- conditions.push(`AccountId = '${escapeSOQL(args.related_account_id)}'`);
28
+ const stage = pickArg({ stage: args.stage, status: args.status });
29
+ const relatedAccountId = pickArg({ related_account_id: args.related_account_id, account_id: args.account_id });
30
+ if (stage)
31
+ conditions.push(`StageName = '${escapeSOQL(stage)}'`);
32
+ if (relatedAccountId)
33
+ conditions.push(`AccountId = '${escapeSOQL(relatedAccountId)}'`);
30
34
  if (args.close_date_from)
31
35
  conditions.push(`CloseDate >= ${formatSOQLDate(args.close_date_from, 'close_date_from')}`);
32
36
  if (args.close_date_to)
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { withErrorHandling, validateObjectName, validateFields, isValidQueryFieldName, isValidFieldName, escapeSOQL, ALLOWED_FILTER_OPERATORS, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords } from '../utils.js';
2
+ import { withErrorHandling, validateObjectName, validateFields, isValidQueryFieldName, isValidFieldName, escapeSOQL, ALLOWED_FILTER_OPERATORS, validateAndMergeCustomFields, checkSaveResult, formatVendorErrors, sanitizeRecords, requireArg } from '../utils.js';
3
3
  import { withConnection } from '../client.js';
4
4
  import { wrapUntrusted } from '../untrusted-content.js';
5
5
  import { ConnectorError } from '../types.js';
@@ -18,9 +18,14 @@ import { ConnectorError } from '../types.js';
18
18
  // corrupted to `WHERE Website = 'https:` (unterminated literal), since
19
19
  // the naive global regex would treat the `//` inside the quoted span as
20
20
  // a line comment.
21
+ //
22
+ // The walker also reports whether the input ended INSIDE an unterminated
23
+ // string literal, which the caller needs to decide whether a trailing `;` is
24
+ // a statement terminator or data the user typed.
21
25
  function stripSoqlComments(query) {
22
26
  let out = '';
23
27
  let i = 0;
28
+ let endedInLiteral = false;
24
29
  const n = query.length;
25
30
  while (i < n) {
26
31
  const ch = query[i];
@@ -30,6 +35,7 @@ function stripSoqlComments(query) {
30
35
  if (ch === "'") {
31
36
  out += ch;
32
37
  i++;
38
+ let closed = false;
33
39
  while (i < n) {
34
40
  const c = query[i];
35
41
  if (c === '\\' && i + 1 < n) {
@@ -48,11 +54,14 @@ function stripSoqlComments(query) {
48
54
  // Closing quote.
49
55
  out += "'";
50
56
  i++;
57
+ closed = true;
51
58
  break;
52
59
  }
53
60
  out += c;
54
61
  i++;
55
62
  }
63
+ if (!closed)
64
+ endedInLiteral = true;
56
65
  continue;
57
66
  }
58
67
  // Outside any quoted literal: strip block + line comments.
@@ -81,7 +90,7 @@ function stripSoqlComments(query) {
81
90
  out += ch;
82
91
  i++;
83
92
  }
84
- return out;
93
+ return { text: out, endedInLiteral };
85
94
  }
86
95
  /**
87
96
  * Enforce a hard cap on the LIMIT clause of a caller-supplied SOQL query.
@@ -97,8 +106,14 @@ function stripSoqlComments(query) {
97
106
  * `OFFSET <n>` clause.
98
107
  */
99
108
  export function applyQueryLimitCap(rawQuery, maxLimit) {
100
- const cleaned = stripSoqlComments(rawQuery).trim();
101
- let base = cleaned;
109
+ const { text, endedInLiteral } = stripSoqlComments(rawQuery);
110
+ const cleaned = text.trim();
111
+ // Callers routinely paste a SQL-style statement terminator. SOQL has none,
112
+ // and a trailing `;` would otherwise defeat the LIMIT/OFFSET matchers below
113
+ // and reach Salesforce as a syntax error. A `;` inside a string literal is
114
+ // data, so only strip when the query did not end inside an unterminated one
115
+ // (a closed literal can never be the trailing run the pattern matches).
116
+ let base = endedInLiteral ? cleaned : cleaned.replace(/[;\s]*;[;\s]*$/, '').trimEnd();
102
117
  let offsetClause = '';
103
118
  const offsetMatch = base.match(/^([\s\S]*?)\s+OFFSET\s+(\d+)\s*$/i);
104
119
  if (offsetMatch) {
@@ -121,28 +136,33 @@ export function applyQueryLimitCap(rawQuery, maxLimit) {
121
136
  }
122
137
  export function registerQueryTools(server) {
123
138
  server.registerTool('salesforce_query', {
124
- description: `Execute a raw SOQL query. For advanced queries only — prefer dedicated tools for standard operations. Max 200 records enforced.`,
139
+ description: `Execute a raw SOQL query. For advanced queries only — prefer dedicated tools for standard operations. Requires query (or its alias soql). Max 200 records enforced.`,
125
140
  inputSchema: z.object({
126
- query: z.string().min(1).describe('SOQL query string'),
141
+ query: z.string().min(1).optional().describe('SOQL query string'),
142
+ soql: z.string().min(1).optional().describe('Alias of query.'),
127
143
  }),
128
144
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
129
145
  }, withErrorHandling(async (args) => {
146
+ const rawQuery = requireArg({ query: args.query, soql: args.soql });
130
147
  return withConnection(undefined, async (conn) => {
131
148
  const MAX_LIMIT = 200;
132
- const query = applyQueryLimitCap(args.query, MAX_LIMIT);
149
+ const query = applyQueryLimitCap(rawQuery, MAX_LIMIT);
133
150
  const result = await conn.query(query);
134
151
  return JSON.stringify({ ok: true, records: sanitizeRecords(result.records, 'salesforce:query:records'), totalSize: result.totalSize, done: result.done });
135
152
  });
136
153
  }));
137
154
  server.registerTool('salesforce_describe_object', {
138
- description: `Get object metadata and field definitions. Returns field names, types, and required flags. Common objects: Account, Contact, Opportunity, Lead, Case, Task.`,
155
+ description: `Get object metadata and field definitions. Returns field names, types, and required flags. Requires object_name (or its alias object). Common objects: Account, Contact, Opportunity, Lead, Case, Task.`,
139
156
  inputSchema: z.object({
140
- object_name: z.string().min(1).describe('Object API name (Account, Contact, Opportunity, Lead, CustomObject__c)'),
157
+ object_name: z.string().min(1).optional().describe('Object API name (Account, Contact, Opportunity, Lead, CustomObject__c)'),
158
+ object: z.string().min(1).optional().describe('Alias of object_name.'),
141
159
  }),
142
160
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
143
161
  }, withErrorHandling(async (args) => {
162
+ const objectName = requireArg({ object_name: args.object_name, object: args.object });
163
+ validateObjectName(objectName);
144
164
  return withConnection(undefined, async (conn) => {
145
- const metadata = await conn.sobject(args.object_name).describe();
165
+ const metadata = await conn.sobject(objectName).describe();
146
166
  // Labels and record-type names are org-authored text — envelope them;
147
167
  // field API names stay raw (they are identifiers, reused in queries).
148
168
  return JSON.stringify({
@@ -204,29 +224,33 @@ export function registerQueryTools(server) {
204
224
  });
205
225
  }));
206
226
  server.registerTool('salesforce_update_record', {
207
- description: `Generic record update for any Salesforce object. For standard objects, prefer dedicated tools.`,
227
+ description: `Generic record update for any Salesforce object. For standard objects, prefer dedicated tools. Requires id (or its alias record_id).`,
208
228
  inputSchema: z.object({
209
229
  object_name: z.string().min(1).describe('sObject API name'),
210
- id: z.string().min(1).describe('Salesforce record ID to update'),
230
+ id: z.string().min(1).optional().describe('Salesforce record ID to update'),
231
+ record_id: z.string().min(1).optional().describe('Alias of id.'),
211
232
  fields: z.record(z.unknown()).describe('Field-value pairs to update'),
212
233
  }),
213
234
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
214
235
  }, withErrorHandling(async (args) => {
215
236
  validateObjectName(args.object_name);
237
+ const recordId = requireArg({ id: args.id, record_id: args.record_id });
216
238
  return withConnection(undefined, async (conn) => {
217
- const updateData = { Id: args.id };
239
+ const updateData = { Id: recordId };
218
240
  validateAndMergeCustomFields(updateData, args.fields);
219
241
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
242
  const result = await conn.sobject(args.object_name).update(updateData);
221
243
  checkSaveResult(result, `Failed to update ${args.object_name} record`);
222
- return JSON.stringify({ ok: true, status: 'success', object: args.object_name, id: args.id });
244
+ return JSON.stringify({ ok: true, status: 'success', object: args.object_name, id: recordId });
223
245
  });
224
246
  }));
225
247
  server.registerTool('salesforce_get_records', {
226
- description: `Generic record query for any Salesforce object. For standard objects, prefer dedicated get tools.`,
248
+ description: `Generic record query for any Salesforce object. Requires object_name (or its alias sobject). For standard objects, prefer dedicated get tools.`,
227
249
  inputSchema: z.object({
228
- object_name: z.string().min(1).describe('sObject API name'),
229
- fields: z.array(z.string()).optional().describe('Fields to SELECT (defaults to Id)'),
250
+ object_name: z.string().min(1).optional().describe('sObject API name'),
251
+ sobject: z.string().min(1).optional().describe('Alias of object_name.'),
252
+ fields: z.union([z.array(z.string()), z.string()]).optional().describe('Fields to SELECT (defaults to Id); array or comma-separated string'),
253
+ record_id: z.string().min(1).optional().describe('Fetch one record by ID (adds an Id = ... condition)'),
230
254
  filters: z.array(z.object({
231
255
  field: z.string().describe('Field API name'),
232
256
  operator: z.enum(['=', '!=', '<', '>', '<=', '>=', 'LIKE']).describe('Comparison operator'),
@@ -236,12 +260,20 @@ export function registerQueryTools(server) {
236
260
  }),
237
261
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
238
262
  }, withErrorHandling(async (args) => {
239
- validateObjectName(args.object_name);
263
+ const objectName = requireArg({ object_name: args.object_name, sobject: args.sobject });
264
+ validateObjectName(objectName);
240
265
  return withConnection(undefined, async (conn) => {
241
- const fields = validateFields(args.fields || [], ['Id'], isValidQueryFieldName);
242
- let soql = `SELECT ${fields.join(', ')} FROM ${args.object_name}`;
266
+ // Callers pass either a list or the comma-separated string they would
267
+ // type into a SELECT; both land in the same field-name validator.
268
+ const requestedFields = typeof args.fields === 'string'
269
+ ? args.fields.split(',').map((f) => f.trim()).filter((f) => f.length > 0)
270
+ : args.fields;
271
+ const fields = validateFields(requestedFields || [], ['Id'], isValidQueryFieldName);
272
+ let soql = `SELECT ${fields.join(', ')} FROM ${objectName}`;
273
+ const conditions = [];
274
+ if (args.record_id)
275
+ conditions.push(`Id = '${escapeSOQL(args.record_id)}'`);
243
276
  if (args.filters && args.filters.length > 0) {
244
- const conditions = [];
245
277
  for (const filter of args.filters) {
246
278
  if (!isValidFieldName(filter.field)) {
247
279
  throw new ConnectorError(`Invalid filter field name: "${filter.field}"`, 'INVALID_FIELD_NAMES', 'Field names must be valid API names');
@@ -258,8 +290,9 @@ export function registerQueryTools(server) {
258
290
  }
259
291
  conditions.push(`${filter.field} ${filter.operator} ${formattedValue}`);
260
292
  }
261
- soql += ` WHERE ${conditions.join(' AND ')}`;
262
293
  }
294
+ if (conditions.length > 0)
295
+ soql += ` WHERE ${conditions.join(' AND ')}`;
263
296
  const limit = Math.min(Math.max(1, args.limit ?? 50), 200);
264
297
  soql += ` LIMIT ${limit}`;
265
298
  const result = await conn.query(soql);
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { withErrorHandling, escapeSOSL, sanitizeRecords } from '../utils.js';
2
+ import { withErrorHandling, escapeSOSL, sanitizeRecords, requireArg } from '../utils.js';
3
3
  import { withConnection } from '../client.js';
4
4
  // Allowlisted object names + fixed RETURNING field lists: the only caller
5
5
  // input reaching the SOSL string is the (escaped) search term, so object and
@@ -16,9 +16,11 @@ const SEARCHABLE_OBJECTS = {
16
16
  const DEFAULT_OBJECTS = ['Account', 'Contact', 'Lead', 'Opportunity'];
17
17
  export function registerSearchTools(server) {
18
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.`,
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. Requires search_term (or its alias query). 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
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'),
21
+ search_term: z.string().min(2).optional().describe('Text to search for (min 2 characters); reserved SOSL characters are escaped automatically'),
22
+ query: z.string().min(2).optional().describe('Alias of search_term.'),
23
+ returnJson: z.boolean().optional().describe('Accepted and ignored — output is always JSON.'),
22
24
  objects: z
23
25
  .array(z.enum(['Account', 'Contact', 'Lead', 'Opportunity', 'Case', 'Task', 'Event']))
24
26
  .optional()
@@ -27,13 +29,14 @@ export function registerSearchTools(server) {
27
29
  }),
28
30
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
29
31
  }, withErrorHandling(async (args) => {
32
+ const searchTerm = requireArg({ search_term: args.search_term, query: args.query });
30
33
  return withConnection(undefined, async (conn) => {
31
34
  const objects = (args.objects && args.objects.length > 0 ? args.objects : DEFAULT_OBJECTS);
32
35
  const returning = objects.map((o) => SEARCHABLE_OBJECTS[o]).join(', ');
33
36
  const limit = Math.min(Math.max(1, args.limit ?? 200), 200);
34
37
  // Probe with one extra record so the caller can tell a complete result
35
38
  // 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}`;
39
+ const sosl = `FIND {${escapeSOSL(searchTerm)}} IN ALL FIELDS RETURNING ${returning} LIMIT ${limit + 1}`;
37
40
  const result = await conn.search(sosl);
38
41
  const allRecords = result.searchRecords ?? [];
39
42
  const truncated = allRecords.length > limit;
package/dist/types.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  export declare const REQUEST_TIMEOUT_MS = 30000;
2
+ /**
3
+ * Bridge budget for the OAuth-initiating `/mcp/configure` call. The host
4
+ * holds that HTTP response open until the user finishes the interactive
5
+ * browser sign-in — both the host's own OAuth flow and this connector's
6
+ * standalone flow cap the wait at 5 minutes. The ordinary 30s
7
+ * REQUEST_TIMEOUT_MS therefore aborts mid-flow on any human-paced connect
8
+ * and reports a spurious failure. 5 minutes + 30s slack for the host's
9
+ * post-auth work (token exchange, config write, reload) — deliberately
10
+ * bounded so a genuinely dead bridge still fails instead of hanging forever.
11
+ */
12
+ export declare const BRIDGE_OAUTH_TIMEOUT_MS: number;
2
13
  export declare const SERVER_NAME = "salesforce-mcp-server";
3
14
  /**
4
15
  * Salesforce REST API version the connector pins every request to. Pinned
package/dist/types.js CHANGED
@@ -2,6 +2,17 @@ import { createRequire } from 'node:module';
2
2
  const require = createRequire(import.meta.url);
3
3
  const pkg = require('../package.json');
4
4
  export const REQUEST_TIMEOUT_MS = 30_000;
5
+ /**
6
+ * Bridge budget for the OAuth-initiating `/mcp/configure` call. The host
7
+ * holds that HTTP response open until the user finishes the interactive
8
+ * browser sign-in — both the host's own OAuth flow and this connector's
9
+ * standalone flow cap the wait at 5 minutes. The ordinary 30s
10
+ * REQUEST_TIMEOUT_MS therefore aborts mid-flow on any human-paced connect
11
+ * and reports a spurious failure. 5 minutes + 30s slack for the host's
12
+ * post-auth work (token exchange, config write, reload) — deliberately
13
+ * bounded so a genuinely dead bridge still fails instead of hanging forever.
14
+ */
15
+ export const BRIDGE_OAUTH_TIMEOUT_MS = 5 * 60 * 1000 + REQUEST_TIMEOUT_MS;
5
16
  export const SERVER_NAME = 'salesforce-mcp-server';
6
17
  /**
7
18
  * Salesforce REST API version the connector pins every request to. Pinned
package/dist/utils.d.ts CHANGED
@@ -34,6 +34,25 @@ export declare function escapeSOSL(term: string): string;
34
34
  * `LIKE` site is unambiguously marked as wildcard-aware.
35
35
  */
36
36
  export declare function escapeSOQLLike(value: string): string;
37
+ /**
38
+ * Resolve a tool argument that accepts more than one spelling.
39
+ *
40
+ * Callers reach for plausible synonyms of an argument name — `soql` for
41
+ * `query`, `sobject` for `object_name` — and the advertised JSON schema is
42
+ * strict (`additionalProperties: false`), so an undeclared key is rejected
43
+ * before the handler ever runs. Aliases are therefore declared in the schema
44
+ * and collapsed here. List the canonical spelling first: the first defined
45
+ * value wins, so the canonical name beats an alias when both are supplied.
46
+ * An empty string counts as undefined and is skipped, so an empty canonical
47
+ * spelling never silently swallows a non-empty alias value.
48
+ */
49
+ export declare function pickArg<T>(candidates: Record<string, T | undefined>): T | undefined;
50
+ /**
51
+ * `pickArg` for an argument that is required but has no single schema-required
52
+ * spelling. Throws a MISSING_ARGUMENT error naming every accepted spelling, so
53
+ * a caller that guessed wrong learns what would have worked.
54
+ */
55
+ export declare function requireArg<T>(candidates: Record<string, T | undefined>): T;
37
56
  /**
38
57
  * Validate that a field name is safe (alphanumeric + underscore only).
39
58
  */
package/dist/utils.js CHANGED
@@ -115,6 +115,41 @@ export function escapeSOSL(term) {
115
115
  export function escapeSOQLLike(value) {
116
116
  return escapeSOQL(value);
117
117
  }
118
+ /**
119
+ * Resolve a tool argument that accepts more than one spelling.
120
+ *
121
+ * Callers reach for plausible synonyms of an argument name — `soql` for
122
+ * `query`, `sobject` for `object_name` — and the advertised JSON schema is
123
+ * strict (`additionalProperties: false`), so an undeclared key is rejected
124
+ * before the handler ever runs. Aliases are therefore declared in the schema
125
+ * and collapsed here. List the canonical spelling first: the first defined
126
+ * value wins, so the canonical name beats an alias when both are supplied.
127
+ * An empty string counts as undefined and is skipped, so an empty canonical
128
+ * spelling never silently swallows a non-empty alias value.
129
+ */
130
+ export function pickArg(candidates) {
131
+ for (const value of Object.values(candidates)) {
132
+ if (value === undefined)
133
+ continue;
134
+ if (typeof value === 'string' && value === '')
135
+ continue;
136
+ return value;
137
+ }
138
+ return undefined;
139
+ }
140
+ /**
141
+ * `pickArg` for an argument that is required but has no single schema-required
142
+ * spelling. Throws a MISSING_ARGUMENT error naming every accepted spelling, so
143
+ * a caller that guessed wrong learns what would have worked.
144
+ */
145
+ export function requireArg(candidates) {
146
+ const value = pickArg(candidates);
147
+ if (value === undefined) {
148
+ const names = Object.keys(candidates);
149
+ throw new ConnectorError(`Missing required argument: provide ${names.join(' or ')}`, 'MISSING_ARGUMENT', `Accepted argument names: ${names.join(', ')}`);
150
+ }
151
+ return value;
152
+ }
118
153
  /**
119
154
  * Validate that a field name is safe (alphanumeric + underscore only).
120
155
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstone/mcp-server-salesforce",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "mcpName": "io.github.mindstone/mcp-server-salesforce",
5
5
  "description": "Salesforce CRM MCP server — accounts, contacts, opportunities, leads, tasks, and custom objects via Salesforce API",
6
6
  "license": "FSL-1.1-MIT",