@snokam/mcp-salesforce 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.releaserc.cjs +41 -0
- package/README.md +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +312 -0
- package/package.json +30 -0
- package/src/index.ts +400 -0
- package/tsconfig.json +17 -0
package/.releaserc.cjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
branches: [{ name: "main" }],
|
|
3
|
+
tagFormat: "mcp-salesforce-v${version}",
|
|
4
|
+
plugins: [
|
|
5
|
+
[
|
|
6
|
+
"@semantic-release/commit-analyzer",
|
|
7
|
+
{
|
|
8
|
+
releaseRules: [
|
|
9
|
+
{ type: "feat", release: "minor" },
|
|
10
|
+
{ type: "fix", release: "patch" },
|
|
11
|
+
{ type: "perf", release: "patch" },
|
|
12
|
+
{ type: "refactor", release: "patch" },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
"@semantic-release/release-notes-generator",
|
|
17
|
+
[
|
|
18
|
+
"@semantic-release/npm",
|
|
19
|
+
{
|
|
20
|
+
pkgRoot: ".",
|
|
21
|
+
npmPublish: true,
|
|
22
|
+
access: "public",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
"@semantic-release/git",
|
|
27
|
+
{
|
|
28
|
+
assets: ["package.json"],
|
|
29
|
+
message:
|
|
30
|
+
"chore(release): mcp-salesforce-v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"@semantic-release/github",
|
|
35
|
+
{
|
|
36
|
+
successComment: false,
|
|
37
|
+
releasedLabels: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
],
|
|
41
|
+
};
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @snokam/salesforce-mcp
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for Salesforce CRM integration. Enables AI agents like Olaf to interact with Salesforce data directly.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🔍 **Search contacts, leads, and accounts**
|
|
8
|
+
- 📊 **View opportunities pipeline**
|
|
9
|
+
- 📝 **Create tasks**
|
|
10
|
+
- 🔧 **Execute custom SOQL queries** (read-only)
|
|
11
|
+
- 📋 **Get recent activities**
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd backend/apps/salesforce-mcp
|
|
17
|
+
pnpm install
|
|
18
|
+
pnpm build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Uses the same environment variables as the existing `chatgpt-function`:
|
|
24
|
+
|
|
25
|
+
```env
|
|
26
|
+
SALESFORCE_CONSUMER_KEY=your_connected_app_consumer_key
|
|
27
|
+
SALESFORCE_CONSUMER_SECRET=your_connected_app_consumer_secret
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Uses `client_credentials` OAuth flow against `https://snokam.my.salesforce.com`.
|
|
31
|
+
|
|
32
|
+
## Usage with mcporter
|
|
33
|
+
|
|
34
|
+
Add to your `~/.mcporter/config.yaml`:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
servers:
|
|
38
|
+
salesforce:
|
|
39
|
+
command: node
|
|
40
|
+
args:
|
|
41
|
+
- /path/to/monorepo/backend/apps/salesforce-mcp/dist/index.js
|
|
42
|
+
env:
|
|
43
|
+
SALESFORCE_CONSUMER_KEY: ${SALESFORCE_CONSUMER_KEY}
|
|
44
|
+
SALESFORCE_CONSUMER_SECRET: ${SALESFORCE_CONSUMER_SECRET}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Available Tools
|
|
48
|
+
|
|
49
|
+
| Tool | Description |
|
|
50
|
+
| --------------------------- | --------------------------------------------------------------- |
|
|
51
|
+
| `search_contacts` | Search contacts by name, email, or company |
|
|
52
|
+
| `search_leads` | Search leads with optional status filter |
|
|
53
|
+
| `search_accounts` | Search accounts/companies with type filter |
|
|
54
|
+
| `get_account` | Get detailed account info with contacts and opportunities |
|
|
55
|
+
| `get_opportunities` | View pipeline with stage/account filters |
|
|
56
|
+
| `get_strategic_contacts` | Get contacts from strategic priority accounts needing follow-up |
|
|
57
|
+
| `get_contact_activities` | Get recent events and tasks for a contact |
|
|
58
|
+
| `get_comprehensive_contact` | Full contact info with account details and activities |
|
|
59
|
+
| `soql_query` | Execute custom SOQL queries (SELECT only) |
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm dev # Run with tsx
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Jira
|
|
68
|
+
|
|
69
|
+
- SNO-3449: Sette opp MCP-server for Salesforce-integrasjon
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// Environment variables - same as existing chatgpt-function
|
|
6
|
+
const SF_CONSUMER_KEY = process.env.SALESFORCE_CONSUMER_KEY;
|
|
7
|
+
const SF_CONSUMER_SECRET = process.env.SALESFORCE_CONSUMER_SECRET;
|
|
8
|
+
const SF_INSTANCE_URL = "https://snokam.my.salesforce.com";
|
|
9
|
+
const SF_API_VERSION = "v62.0";
|
|
10
|
+
let accessToken = null;
|
|
11
|
+
async function getAccessToken() {
|
|
12
|
+
if (accessToken) {
|
|
13
|
+
return accessToken;
|
|
14
|
+
}
|
|
15
|
+
if (!SF_CONSUMER_KEY || !SF_CONSUMER_SECRET) {
|
|
16
|
+
throw new Error("SALESFORCE_CONSUMER_KEY and SALESFORCE_CONSUMER_SECRET environment variables are required");
|
|
17
|
+
}
|
|
18
|
+
const params = new URLSearchParams({
|
|
19
|
+
client_id: SF_CONSUMER_KEY,
|
|
20
|
+
client_secret: SF_CONSUMER_SECRET,
|
|
21
|
+
grant_type: "client_credentials",
|
|
22
|
+
});
|
|
23
|
+
const response = await fetch(`${SF_INSTANCE_URL}/services/oauth2/token`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
26
|
+
body: params,
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const error = await response.text();
|
|
30
|
+
throw new Error(`Failed to get access token: ${response.status} - ${error}`);
|
|
31
|
+
}
|
|
32
|
+
const data = await response.json();
|
|
33
|
+
accessToken = data.access_token;
|
|
34
|
+
return accessToken;
|
|
35
|
+
}
|
|
36
|
+
async function sfQuery(soql) {
|
|
37
|
+
const token = await getAccessToken();
|
|
38
|
+
const url = `${SF_INSTANCE_URL}/services/data/${SF_API_VERSION}/query?q=${encodeURIComponent(soql)}`;
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const error = await response.text();
|
|
44
|
+
throw new Error(`Salesforce query failed: ${response.status} - ${error}`);
|
|
45
|
+
}
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
return data.records;
|
|
48
|
+
}
|
|
49
|
+
const server = new McpServer({
|
|
50
|
+
name: "salesforce-mcp",
|
|
51
|
+
version: "1.0.0",
|
|
52
|
+
});
|
|
53
|
+
// Tool: Search Contacts
|
|
54
|
+
server.tool("search_contacts", "Search for contacts in Salesforce by name, email, or company", {
|
|
55
|
+
query: z.string().describe("Search query (name, email, or company)"),
|
|
56
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
57
|
+
}, async ({ query, limit }) => {
|
|
58
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
59
|
+
const soql = `
|
|
60
|
+
SELECT Id, Name, Email, Phone, Title, Account.Name, Account.Strategic_Priority__c, LastActivityDate
|
|
61
|
+
FROM Contact
|
|
62
|
+
WHERE Name LIKE '%${escapedQuery}%' OR Email LIKE '%${escapedQuery}%' OR Account.Name LIKE '%${escapedQuery}%'
|
|
63
|
+
ORDER BY LastActivityDate DESC NULLS LAST
|
|
64
|
+
LIMIT ${limit}
|
|
65
|
+
`;
|
|
66
|
+
const records = await sfQuery(soql);
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
// Tool: Search Leads
|
|
72
|
+
server.tool("search_leads", "Search for leads in Salesforce", {
|
|
73
|
+
query: z.string().describe("Search query"),
|
|
74
|
+
status: z
|
|
75
|
+
.string()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Lead status filter (e.g., Open, Working, Closed)"),
|
|
78
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
79
|
+
}, async ({ query, status, limit }) => {
|
|
80
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
81
|
+
let whereClause = `Name LIKE '%${escapedQuery}%' OR Email LIKE '%${escapedQuery}%' OR Company LIKE '%${escapedQuery}%'`;
|
|
82
|
+
if (status) {
|
|
83
|
+
whereClause = `(${whereClause}) AND Status = '${status}'`;
|
|
84
|
+
}
|
|
85
|
+
const soql = `
|
|
86
|
+
SELECT Id, Name, Email, Phone, Company, Status, LeadSource, CreatedDate
|
|
87
|
+
FROM Lead
|
|
88
|
+
WHERE ${whereClause}
|
|
89
|
+
ORDER BY CreatedDate DESC
|
|
90
|
+
LIMIT ${limit}
|
|
91
|
+
`;
|
|
92
|
+
const records = await sfQuery(soql);
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
// Tool: Get Opportunities
|
|
98
|
+
server.tool("get_opportunities", "Get opportunities from Salesforce pipeline", {
|
|
99
|
+
stage: z
|
|
100
|
+
.string()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Filter by stage (e.g., Prospecting, Negotiation, Closed Won)"),
|
|
103
|
+
accountId: z.string().optional().describe("Filter by account ID"),
|
|
104
|
+
limit: z.number().default(20).describe("Maximum number of results"),
|
|
105
|
+
}, async ({ stage, accountId, limit }) => {
|
|
106
|
+
const conditions = [];
|
|
107
|
+
if (stage)
|
|
108
|
+
conditions.push(`StageName = '${stage}'`);
|
|
109
|
+
if (accountId)
|
|
110
|
+
conditions.push(`AccountId = '${accountId}'`);
|
|
111
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
112
|
+
const soql = `
|
|
113
|
+
SELECT Id, Name, StageName, Amount, CloseDate, Account.Name, Owner.Name, Probability
|
|
114
|
+
FROM Opportunity
|
|
115
|
+
${whereClause}
|
|
116
|
+
ORDER BY CloseDate ASC
|
|
117
|
+
LIMIT ${limit}
|
|
118
|
+
`;
|
|
119
|
+
const records = await sfQuery(soql);
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
// Tool: Get Account Details
|
|
125
|
+
server.tool("get_account", "Get detailed information about a Salesforce account", {
|
|
126
|
+
accountId: z.string().describe("Salesforce Account ID"),
|
|
127
|
+
}, async ({ accountId }) => {
|
|
128
|
+
const soql = `
|
|
129
|
+
SELECT Id, Name, Industry, Website, Phone, BillingCity, BillingCountry,
|
|
130
|
+
Description, NumberOfEmployees, AnnualRevenue, Owner.Name, Type,
|
|
131
|
+
Strategic_Priority__c, Technology__c, LastActivityDate,
|
|
132
|
+
(SELECT Id, Name, Email, Title FROM Contacts LIMIT 10),
|
|
133
|
+
(SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunities ORDER BY CloseDate DESC LIMIT 5)
|
|
134
|
+
FROM Account
|
|
135
|
+
WHERE Id = '${accountId}'
|
|
136
|
+
`;
|
|
137
|
+
const records = await sfQuery(soql);
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: "text", text: JSON.stringify(records[0], null, 2) }],
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
// Tool: Search Accounts
|
|
143
|
+
server.tool("search_accounts", "Search for accounts/companies in Salesforce", {
|
|
144
|
+
query: z.string().describe("Search query (company name)"),
|
|
145
|
+
type: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe("Account type filter (e.g., Prospect, Customer)"),
|
|
149
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
150
|
+
}, async ({ query, type, limit }) => {
|
|
151
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
152
|
+
let whereClause = `Name LIKE '%${escapedQuery}%'`;
|
|
153
|
+
if (type) {
|
|
154
|
+
whereClause += ` AND Type = '${type}'`;
|
|
155
|
+
}
|
|
156
|
+
const soql = `
|
|
157
|
+
SELECT Id, Name, Industry, Website, Phone, BillingCity, Owner.Name, Type,
|
|
158
|
+
Strategic_Priority__c, Technology__c, LastActivityDate
|
|
159
|
+
FROM Account
|
|
160
|
+
WHERE ${whereClause}
|
|
161
|
+
ORDER BY Name ASC
|
|
162
|
+
LIMIT ${limit}
|
|
163
|
+
`;
|
|
164
|
+
const records = await sfQuery(soql);
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
// Tool: Get Strategic Contacts (for follow-up)
|
|
170
|
+
server.tool("get_strategic_contacts", "Get contacts from strategic priority accounts that need follow-up", {
|
|
171
|
+
priority: z
|
|
172
|
+
.enum(["A", "B", "C", "D"])
|
|
173
|
+
.default("D")
|
|
174
|
+
.describe("Strategic priority filter"),
|
|
175
|
+
daysInactive: z.number().default(90).describe("Days since last activity"),
|
|
176
|
+
limit: z.number().default(20).describe("Maximum number of results"),
|
|
177
|
+
}, async ({ priority, daysInactive, limit }) => {
|
|
178
|
+
const cutoffDate = new Date(Date.now() - daysInactive * 24 * 60 * 60 * 1000)
|
|
179
|
+
.toISOString()
|
|
180
|
+
.split("T")[0];
|
|
181
|
+
const soql = `
|
|
182
|
+
SELECT Id, Name, Title, Email, Account.Name, Account.Strategic_Priority__c,
|
|
183
|
+
Account.Technology__c, Account.Description, Account.LastActivityDate
|
|
184
|
+
FROM Contact
|
|
185
|
+
WHERE Account.Type = 'Prospect'
|
|
186
|
+
AND Account.Strategic_Priority__c = '${priority}'
|
|
187
|
+
AND Email != null
|
|
188
|
+
AND (Account.LastActivityDate < ${cutoffDate} OR Account.LastActivityDate = null)
|
|
189
|
+
LIMIT ${limit}
|
|
190
|
+
`;
|
|
191
|
+
const records = await sfQuery(soql);
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
// Tool: Execute SOQL Query
|
|
197
|
+
server.tool("soql_query", "Execute a custom SOQL query against Salesforce (read-only)", {
|
|
198
|
+
query: z.string().describe("SOQL query to execute"),
|
|
199
|
+
}, async ({ query }) => {
|
|
200
|
+
// Basic safety check - only allow SELECT queries
|
|
201
|
+
if (!query.trim().toUpperCase().startsWith("SELECT")) {
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{ type: "text", text: "Error: Only SELECT queries are allowed" },
|
|
205
|
+
],
|
|
206
|
+
isError: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const records = await sfQuery(query);
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
// Tool: Get Recent Activities for Contact
|
|
215
|
+
server.tool("get_contact_activities", "Get recent activities (events and tasks) for a specific contact", {
|
|
216
|
+
contactId: z.string().describe("Salesforce Contact ID"),
|
|
217
|
+
daysBack: z.number().default(90).describe("Number of days to look back"),
|
|
218
|
+
}, async ({ contactId, daysBack }) => {
|
|
219
|
+
const cutoffDate = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString();
|
|
220
|
+
// Get events
|
|
221
|
+
const eventsSoql = `
|
|
222
|
+
SELECT Id, Subject, Description, ActivityDateTime
|
|
223
|
+
FROM Event
|
|
224
|
+
WHERE WhoId = '${contactId}'
|
|
225
|
+
AND ActivityDateTime > ${cutoffDate}
|
|
226
|
+
ORDER BY ActivityDateTime DESC
|
|
227
|
+
`;
|
|
228
|
+
// Get tasks
|
|
229
|
+
const tasksSoql = `
|
|
230
|
+
SELECT Id, Subject, Description, ActivityDate, Status
|
|
231
|
+
FROM Task
|
|
232
|
+
WHERE WhoId = '${contactId}'
|
|
233
|
+
AND ActivityDate > ${cutoffDate.split("T")[0]}
|
|
234
|
+
ORDER BY ActivityDate DESC
|
|
235
|
+
`;
|
|
236
|
+
const [events, tasks] = await Promise.all([
|
|
237
|
+
sfQuery(eventsSoql),
|
|
238
|
+
sfQuery(tasksSoql),
|
|
239
|
+
]);
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: JSON.stringify({ events, tasks }, null, 2),
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
// Tool: Get Comprehensive Contact Info (like existing chatgpt-function)
|
|
250
|
+
server.tool("get_comprehensive_contact", "Get comprehensive contact information including account details and recent activities", {
|
|
251
|
+
contactId: z.string().describe("Salesforce Contact ID"),
|
|
252
|
+
daysBack: z
|
|
253
|
+
.number()
|
|
254
|
+
.default(90)
|
|
255
|
+
.describe("Number of days to look back for activities"),
|
|
256
|
+
}, async ({ contactId, daysBack }) => {
|
|
257
|
+
// Get contact details
|
|
258
|
+
const contactSoql = `
|
|
259
|
+
SELECT Id, Name, Title, Email, Phone, Description,
|
|
260
|
+
Account.Name, Account.Strategic_Priority__c, Account.Technology__c,
|
|
261
|
+
Account.Description, Account.Industry, Account.Website
|
|
262
|
+
FROM Contact
|
|
263
|
+
WHERE Id = '${contactId}'
|
|
264
|
+
`;
|
|
265
|
+
const contacts = await sfQuery(contactSoql);
|
|
266
|
+
const contact = contacts[0];
|
|
267
|
+
if (!contact) {
|
|
268
|
+
return {
|
|
269
|
+
content: [
|
|
270
|
+
{ type: "text", text: `No contact found with ID: ${contactId}` },
|
|
271
|
+
],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const cutoffDate = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString();
|
|
276
|
+
// Get events and tasks
|
|
277
|
+
const eventsSoql = `
|
|
278
|
+
SELECT Id, Subject, Description, ActivityDateTime
|
|
279
|
+
FROM Event
|
|
280
|
+
WHERE WhoId = '${contactId}'
|
|
281
|
+
AND ActivityDateTime > ${cutoffDate}
|
|
282
|
+
ORDER BY ActivityDateTime DESC
|
|
283
|
+
LIMIT 20
|
|
284
|
+
`;
|
|
285
|
+
const tasksSoql = `
|
|
286
|
+
SELECT Id, Subject, Description, ActivityDate, Status
|
|
287
|
+
FROM Task
|
|
288
|
+
WHERE WhoId = '${contactId}'
|
|
289
|
+
AND ActivityDate > ${cutoffDate.split("T")[0]}
|
|
290
|
+
ORDER BY ActivityDate DESC
|
|
291
|
+
LIMIT 20
|
|
292
|
+
`;
|
|
293
|
+
const [events, tasks] = await Promise.all([
|
|
294
|
+
sfQuery(eventsSoql),
|
|
295
|
+
sfQuery(tasksSoql),
|
|
296
|
+
]);
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: "text",
|
|
301
|
+
text: JSON.stringify({ contact, events, tasks }, null, 2),
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
// Start the server
|
|
307
|
+
async function main() {
|
|
308
|
+
const transport = new StdioServerTransport();
|
|
309
|
+
await server.connect(transport);
|
|
310
|
+
console.error("Salesforce MCP server running on stdio");
|
|
311
|
+
}
|
|
312
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snokam/mcp-salesforce",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for Salesforce CRM integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"salesforce-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "tsx src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
17
|
+
"zod": "^3.23.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsx": "^4.19.0",
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// Environment variables - same as existing chatgpt-function
|
|
7
|
+
const SF_CONSUMER_KEY = process.env.SALESFORCE_CONSUMER_KEY;
|
|
8
|
+
const SF_CONSUMER_SECRET = process.env.SALESFORCE_CONSUMER_SECRET;
|
|
9
|
+
const SF_INSTANCE_URL = "https://snokam.my.salesforce.com";
|
|
10
|
+
const SF_API_VERSION = "v62.0";
|
|
11
|
+
|
|
12
|
+
let accessToken: string | null = null;
|
|
13
|
+
|
|
14
|
+
async function getAccessToken(): Promise<string> {
|
|
15
|
+
if (accessToken) {
|
|
16
|
+
return accessToken;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!SF_CONSUMER_KEY || !SF_CONSUMER_SECRET) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"SALESFORCE_CONSUMER_KEY and SALESFORCE_CONSUMER_SECRET environment variables are required"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const params = new URLSearchParams({
|
|
26
|
+
client_id: SF_CONSUMER_KEY,
|
|
27
|
+
client_secret: SF_CONSUMER_SECRET,
|
|
28
|
+
grant_type: "client_credentials",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const response = await fetch(`${SF_INSTANCE_URL}/services/oauth2/token`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
34
|
+
body: params,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const error = await response.text();
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Failed to get access token: ${response.status} - ${error}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
accessToken = data.access_token;
|
|
46
|
+
return accessToken!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function sfQuery<T = unknown>(soql: string): Promise<T[]> {
|
|
50
|
+
const token = await getAccessToken();
|
|
51
|
+
const url = `${SF_INSTANCE_URL}/services/data/${SF_API_VERSION}/query?q=${encodeURIComponent(soql)}`;
|
|
52
|
+
|
|
53
|
+
const response = await fetch(url, {
|
|
54
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const error = await response.text();
|
|
59
|
+
throw new Error(`Salesforce query failed: ${response.status} - ${error}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
return data.records as T[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const server = new McpServer({
|
|
67
|
+
name: "salesforce-mcp",
|
|
68
|
+
version: "1.0.0",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Tool: Search Contacts
|
|
72
|
+
server.tool(
|
|
73
|
+
"search_contacts",
|
|
74
|
+
"Search for contacts in Salesforce by name, email, or company",
|
|
75
|
+
{
|
|
76
|
+
query: z.string().describe("Search query (name, email, or company)"),
|
|
77
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
78
|
+
},
|
|
79
|
+
async ({ query, limit }) => {
|
|
80
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
81
|
+
const soql = `
|
|
82
|
+
SELECT Id, Name, Email, Phone, Title, Account.Name, Account.Strategic_Priority__c, LastActivityDate
|
|
83
|
+
FROM Contact
|
|
84
|
+
WHERE Name LIKE '%${escapedQuery}%' OR Email LIKE '%${escapedQuery}%' OR Account.Name LIKE '%${escapedQuery}%'
|
|
85
|
+
ORDER BY LastActivityDate DESC NULLS LAST
|
|
86
|
+
LIMIT ${limit}
|
|
87
|
+
`;
|
|
88
|
+
const records = await sfQuery(soql);
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Tool: Search Leads
|
|
96
|
+
server.tool(
|
|
97
|
+
"search_leads",
|
|
98
|
+
"Search for leads in Salesforce",
|
|
99
|
+
{
|
|
100
|
+
query: z.string().describe("Search query"),
|
|
101
|
+
status: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Lead status filter (e.g., Open, Working, Closed)"),
|
|
105
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
106
|
+
},
|
|
107
|
+
async ({ query, status, limit }) => {
|
|
108
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
109
|
+
let whereClause = `Name LIKE '%${escapedQuery}%' OR Email LIKE '%${escapedQuery}%' OR Company LIKE '%${escapedQuery}%'`;
|
|
110
|
+
if (status) {
|
|
111
|
+
whereClause = `(${whereClause}) AND Status = '${status}'`;
|
|
112
|
+
}
|
|
113
|
+
const soql = `
|
|
114
|
+
SELECT Id, Name, Email, Phone, Company, Status, LeadSource, CreatedDate
|
|
115
|
+
FROM Lead
|
|
116
|
+
WHERE ${whereClause}
|
|
117
|
+
ORDER BY CreatedDate DESC
|
|
118
|
+
LIMIT ${limit}
|
|
119
|
+
`;
|
|
120
|
+
const records = await sfQuery(soql);
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Tool: Get Opportunities
|
|
128
|
+
server.tool(
|
|
129
|
+
"get_opportunities",
|
|
130
|
+
"Get opportunities from Salesforce pipeline",
|
|
131
|
+
{
|
|
132
|
+
stage: z
|
|
133
|
+
.string()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("Filter by stage (e.g., Prospecting, Negotiation, Closed Won)"),
|
|
136
|
+
accountId: z.string().optional().describe("Filter by account ID"),
|
|
137
|
+
limit: z.number().default(20).describe("Maximum number of results"),
|
|
138
|
+
},
|
|
139
|
+
async ({ stage, accountId, limit }) => {
|
|
140
|
+
const conditions: string[] = [];
|
|
141
|
+
if (stage) conditions.push(`StageName = '${stage}'`);
|
|
142
|
+
if (accountId) conditions.push(`AccountId = '${accountId}'`);
|
|
143
|
+
|
|
144
|
+
const whereClause =
|
|
145
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
146
|
+
const soql = `
|
|
147
|
+
SELECT Id, Name, StageName, Amount, CloseDate, Account.Name, Owner.Name, Probability
|
|
148
|
+
FROM Opportunity
|
|
149
|
+
${whereClause}
|
|
150
|
+
ORDER BY CloseDate ASC
|
|
151
|
+
LIMIT ${limit}
|
|
152
|
+
`;
|
|
153
|
+
const records = await sfQuery(soql);
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Tool: Get Account Details
|
|
161
|
+
server.tool(
|
|
162
|
+
"get_account",
|
|
163
|
+
"Get detailed information about a Salesforce account",
|
|
164
|
+
{
|
|
165
|
+
accountId: z.string().describe("Salesforce Account ID"),
|
|
166
|
+
},
|
|
167
|
+
async ({ accountId }) => {
|
|
168
|
+
const soql = `
|
|
169
|
+
SELECT Id, Name, Industry, Website, Phone, BillingCity, BillingCountry,
|
|
170
|
+
Description, NumberOfEmployees, AnnualRevenue, Owner.Name, Type,
|
|
171
|
+
Strategic_Priority__c, Technology__c, LastActivityDate,
|
|
172
|
+
(SELECT Id, Name, Email, Title FROM Contacts LIMIT 10),
|
|
173
|
+
(SELECT Id, Name, StageName, Amount, CloseDate FROM Opportunities ORDER BY CloseDate DESC LIMIT 5)
|
|
174
|
+
FROM Account
|
|
175
|
+
WHERE Id = '${accountId}'
|
|
176
|
+
`;
|
|
177
|
+
const records = await sfQuery(soql);
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text: JSON.stringify(records[0], null, 2) }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Tool: Search Accounts
|
|
185
|
+
server.tool(
|
|
186
|
+
"search_accounts",
|
|
187
|
+
"Search for accounts/companies in Salesforce",
|
|
188
|
+
{
|
|
189
|
+
query: z.string().describe("Search query (company name)"),
|
|
190
|
+
type: z
|
|
191
|
+
.string()
|
|
192
|
+
.optional()
|
|
193
|
+
.describe("Account type filter (e.g., Prospect, Customer)"),
|
|
194
|
+
limit: z.number().default(10).describe("Maximum number of results"),
|
|
195
|
+
},
|
|
196
|
+
async ({ query, type, limit }) => {
|
|
197
|
+
const escapedQuery = query.replace(/'/g, "\\'");
|
|
198
|
+
let whereClause = `Name LIKE '%${escapedQuery}%'`;
|
|
199
|
+
if (type) {
|
|
200
|
+
whereClause += ` AND Type = '${type}'`;
|
|
201
|
+
}
|
|
202
|
+
const soql = `
|
|
203
|
+
SELECT Id, Name, Industry, Website, Phone, BillingCity, Owner.Name, Type,
|
|
204
|
+
Strategic_Priority__c, Technology__c, LastActivityDate
|
|
205
|
+
FROM Account
|
|
206
|
+
WHERE ${whereClause}
|
|
207
|
+
ORDER BY Name ASC
|
|
208
|
+
LIMIT ${limit}
|
|
209
|
+
`;
|
|
210
|
+
const records = await sfQuery(soql);
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Tool: Get Strategic Contacts (for follow-up)
|
|
218
|
+
server.tool(
|
|
219
|
+
"get_strategic_contacts",
|
|
220
|
+
"Get contacts from strategic priority accounts that need follow-up",
|
|
221
|
+
{
|
|
222
|
+
priority: z
|
|
223
|
+
.enum(["A", "B", "C", "D"])
|
|
224
|
+
.default("D")
|
|
225
|
+
.describe("Strategic priority filter"),
|
|
226
|
+
daysInactive: z.number().default(90).describe("Days since last activity"),
|
|
227
|
+
limit: z.number().default(20).describe("Maximum number of results"),
|
|
228
|
+
},
|
|
229
|
+
async ({ priority, daysInactive, limit }) => {
|
|
230
|
+
const cutoffDate = new Date(Date.now() - daysInactive * 24 * 60 * 60 * 1000)
|
|
231
|
+
.toISOString()
|
|
232
|
+
.split("T")[0];
|
|
233
|
+
const soql = `
|
|
234
|
+
SELECT Id, Name, Title, Email, Account.Name, Account.Strategic_Priority__c,
|
|
235
|
+
Account.Technology__c, Account.Description, Account.LastActivityDate
|
|
236
|
+
FROM Contact
|
|
237
|
+
WHERE Account.Type = 'Prospect'
|
|
238
|
+
AND Account.Strategic_Priority__c = '${priority}'
|
|
239
|
+
AND Email != null
|
|
240
|
+
AND (Account.LastActivityDate < ${cutoffDate} OR Account.LastActivityDate = null)
|
|
241
|
+
LIMIT ${limit}
|
|
242
|
+
`;
|
|
243
|
+
const records = await sfQuery(soql);
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Tool: Execute SOQL Query
|
|
251
|
+
server.tool(
|
|
252
|
+
"soql_query",
|
|
253
|
+
"Execute a custom SOQL query against Salesforce (read-only)",
|
|
254
|
+
{
|
|
255
|
+
query: z.string().describe("SOQL query to execute"),
|
|
256
|
+
},
|
|
257
|
+
async ({ query }) => {
|
|
258
|
+
// Basic safety check - only allow SELECT queries
|
|
259
|
+
if (!query.trim().toUpperCase().startsWith("SELECT")) {
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
{ type: "text", text: "Error: Only SELECT queries are allowed" },
|
|
263
|
+
],
|
|
264
|
+
isError: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const records = await sfQuery(query);
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text", text: JSON.stringify(records, null, 2) }],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Tool: Get Recent Activities for Contact
|
|
275
|
+
server.tool(
|
|
276
|
+
"get_contact_activities",
|
|
277
|
+
"Get recent activities (events and tasks) for a specific contact",
|
|
278
|
+
{
|
|
279
|
+
contactId: z.string().describe("Salesforce Contact ID"),
|
|
280
|
+
daysBack: z.number().default(90).describe("Number of days to look back"),
|
|
281
|
+
},
|
|
282
|
+
async ({ contactId, daysBack }) => {
|
|
283
|
+
const cutoffDate = new Date(
|
|
284
|
+
Date.now() - daysBack * 24 * 60 * 60 * 1000
|
|
285
|
+
).toISOString();
|
|
286
|
+
|
|
287
|
+
// Get events
|
|
288
|
+
const eventsSoql = `
|
|
289
|
+
SELECT Id, Subject, Description, ActivityDateTime
|
|
290
|
+
FROM Event
|
|
291
|
+
WHERE WhoId = '${contactId}'
|
|
292
|
+
AND ActivityDateTime > ${cutoffDate}
|
|
293
|
+
ORDER BY ActivityDateTime DESC
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
// Get tasks
|
|
297
|
+
const tasksSoql = `
|
|
298
|
+
SELECT Id, Subject, Description, ActivityDate, Status
|
|
299
|
+
FROM Task
|
|
300
|
+
WHERE WhoId = '${contactId}'
|
|
301
|
+
AND ActivityDate > ${cutoffDate.split("T")[0]}
|
|
302
|
+
ORDER BY ActivityDate DESC
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
const [events, tasks] = await Promise.all([
|
|
306
|
+
sfQuery(eventsSoql),
|
|
307
|
+
sfQuery(tasksSoql),
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
content: [
|
|
312
|
+
{
|
|
313
|
+
type: "text",
|
|
314
|
+
text: JSON.stringify({ events, tasks }, null, 2),
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Tool: Get Comprehensive Contact Info (like existing chatgpt-function)
|
|
322
|
+
server.tool(
|
|
323
|
+
"get_comprehensive_contact",
|
|
324
|
+
"Get comprehensive contact information including account details and recent activities",
|
|
325
|
+
{
|
|
326
|
+
contactId: z.string().describe("Salesforce Contact ID"),
|
|
327
|
+
daysBack: z
|
|
328
|
+
.number()
|
|
329
|
+
.default(90)
|
|
330
|
+
.describe("Number of days to look back for activities"),
|
|
331
|
+
},
|
|
332
|
+
async ({ contactId, daysBack }) => {
|
|
333
|
+
// Get contact details
|
|
334
|
+
const contactSoql = `
|
|
335
|
+
SELECT Id, Name, Title, Email, Phone, Description,
|
|
336
|
+
Account.Name, Account.Strategic_Priority__c, Account.Technology__c,
|
|
337
|
+
Account.Description, Account.Industry, Account.Website
|
|
338
|
+
FROM Contact
|
|
339
|
+
WHERE Id = '${contactId}'
|
|
340
|
+
`;
|
|
341
|
+
|
|
342
|
+
const contacts = await sfQuery(contactSoql);
|
|
343
|
+
const contact = contacts[0];
|
|
344
|
+
|
|
345
|
+
if (!contact) {
|
|
346
|
+
return {
|
|
347
|
+
content: [
|
|
348
|
+
{ type: "text", text: `No contact found with ID: ${contactId}` },
|
|
349
|
+
],
|
|
350
|
+
isError: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const cutoffDate = new Date(
|
|
355
|
+
Date.now() - daysBack * 24 * 60 * 60 * 1000
|
|
356
|
+
).toISOString();
|
|
357
|
+
|
|
358
|
+
// Get events and tasks
|
|
359
|
+
const eventsSoql = `
|
|
360
|
+
SELECT Id, Subject, Description, ActivityDateTime
|
|
361
|
+
FROM Event
|
|
362
|
+
WHERE WhoId = '${contactId}'
|
|
363
|
+
AND ActivityDateTime > ${cutoffDate}
|
|
364
|
+
ORDER BY ActivityDateTime DESC
|
|
365
|
+
LIMIT 20
|
|
366
|
+
`;
|
|
367
|
+
|
|
368
|
+
const tasksSoql = `
|
|
369
|
+
SELECT Id, Subject, Description, ActivityDate, Status
|
|
370
|
+
FROM Task
|
|
371
|
+
WHERE WhoId = '${contactId}'
|
|
372
|
+
AND ActivityDate > ${cutoffDate.split("T")[0]}
|
|
373
|
+
ORDER BY ActivityDate DESC
|
|
374
|
+
LIMIT 20
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
const [events, tasks] = await Promise.all([
|
|
378
|
+
sfQuery(eventsSoql),
|
|
379
|
+
sfQuery(tasksSoql),
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
content: [
|
|
384
|
+
{
|
|
385
|
+
type: "text",
|
|
386
|
+
text: JSON.stringify({ contact, events, tasks }, null, 2),
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Start the server
|
|
394
|
+
async function main() {
|
|
395
|
+
const transport = new StdioServerTransport();
|
|
396
|
+
await server.connect(transport);
|
|
397
|
+
console.error("Salesforce MCP server running on stdio");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
main().catch(console.error);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"resolveJsonModule": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|