@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 +1 -1
- package/dist/bridge.d.ts +6 -1
- package/dist/bridge.js +7 -2
- package/dist/tools/accounts.js +7 -4
- package/dist/tools/auth-tools.js +6 -1
- package/dist/tools/opportunities.js +9 -5
- package/dist/tools/query.js +55 -22
- package/dist/tools/search.js +7 -4
- package/dist/types.d.ts +11 -0
- package/dist/types.js +11 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +35 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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(
|
|
33
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
29
34
|
headers: {
|
|
30
35
|
'Content-Type': 'application/json',
|
|
31
36
|
Authorization: `Bearer ${bridge.token}`,
|
package/dist/tools/accounts.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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 });
|
package/dist/tools/auth-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
conditions.push(`
|
|
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)
|
package/dist/tools/query.js
CHANGED
|
@@ -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
|
|
101
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
const objectName = requireArg({ object_name: args.object_name, sobject: args.sobject });
|
|
264
|
+
validateObjectName(objectName);
|
|
240
265
|
return withConnection(undefined, async (conn) => {
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
package/dist/tools/search.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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",
|