Guide · CRM & Customer Support
MCP Tools for Salesforce — OAuth2 Connected App, instance URL routing, governor limits, and composite API
Salesforce is the dominant enterprise CRM, and MCP tools integrating with it must navigate two hard constraints that differ from every other SaaS API: the instance URL is not fixed — it is returned in the token response and routes each org to its own pod (e.g., https://mycompany.my.salesforce.com); and governor limits are per-org, not per-key — your MCP tool shares a 24-hour API call quota with every other integration the customer runs. A health probe that only checks OAuth2 token validity misses both of these constraints. The platform requires a Connected App registered in Salesforce Setup for machine-to-machine integrations; personal OAuth apps do not exist. Sandbox orgs (test.salesforce.com) and production orgs (login.salesforce.com) use different token endpoints and different instance URLs.
TL;DR
OAuth2 client_credentials (server-to-server): POST https://login.salesforce.com/services/oauth2/token with grant_type=client_credentials&client_id=xxx&client_secret=xxx. Token response includes access_token and instance_url — store the instance URL, all REST API calls go to {instance_url}/services/data/v{version}/. SOQL: GET {instance_url}/services/data/v{version}/query?q=SELECT+Id+FROM+Account+LIMIT+1 with Authorization: Bearer {access_token}. Check remaining governor limits: GET /services/data/v{version}/limits returns DailyApiRequests.Remaining. Health probe: verify token, then fetch /limits and confirm DailyApiRequests.Remaining > 0. Sandbox token endpoint: test.salesforce.com not login.salesforce.com.
Connected App setup and OAuth2 flows
Salesforce requires a Connected App — a configuration object in Salesforce Setup — before any machine authentication can happen. There is no API-key equivalent for server access. Two flows matter for MCP tools:
- Client Credentials flow (recommended for server-to-server): uses
client_id+client_secret; requires "Client Credentials" OAuth scope enabled in the Connected App and a "Run As" user with appropriate profiles. No user interaction required. - JWT Bearer flow: uses a private key to sign an assertion; requires a certificate uploaded to the Connected App. More secure for zero-secret deployments (private key in HSM, never transmitted).
The username-password flow is legacy and disabled by default in newer Salesforce orgs. Do not use it for new integrations — Salesforce deprecated it for security reasons.
// Salesforce client — caches token until 5 minutes before expiry
class SalesforceClient {
#token = null;
#instanceUrl = null;
#tokenExpiresAt = 0;
constructor(clientId, clientSecret, loginUrl = 'https://login.salesforce.com') {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.loginUrl = loginUrl; // use 'https://test.salesforce.com' for sandbox
this.apiVersion = 'v61.0'; // update each Salesforce release cycle
}
async #refreshToken() {
const res = await fetch(`${this.loginUrl}/services/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
}),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Salesforce token error ${res.status}: ${body.slice(0, 200)}`);
}
const data = await res.json();
this.#token = data.access_token;
// instance_url is critical — it routes to the org's dedicated pod
// e.g., "https://acme.my.salesforce.com" — NOT "https://login.salesforce.com"
this.#instanceUrl = data.instance_url;
// issued_at is epoch milliseconds; tokens typically valid 2 hours
const issuedAt = parseInt(data.issued_at, 10);
this.#tokenExpiresAt = issuedAt + 2 * 60 * 60 * 1000 - 5 * 60 * 1000; // 5min buffer
}
async getToken() {
if (!this.#token || Date.now() > this.#tokenExpiresAt) {
await this.#refreshToken();
}
return { token: this.#token, instanceUrl: this.#instanceUrl };
}
async request(path, method = 'GET', body = null) {
const { token, instanceUrl } = await this.getToken();
const url = `${instanceUrl}/services/data/${this.apiVersion}${path}`;
const res = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: body ? JSON.stringify(body) : null,
signal: AbortSignal.timeout(30_000),
});
// Salesforce returns 401 with errorCode INVALID_SESSION_ID on token expiry
if (res.status === 401) {
this.#token = null; // force re-auth on next call
throw new Error('Salesforce session expired — retry');
}
if (!res.ok) {
const errors = await res.json().catch(() => [{ message: 'unknown' }]);
const msg = Array.isArray(errors) ? errors[0]?.message : errors.message;
throw new Error(`Salesforce ${method} ${path} → ${res.status}: ${msg}`);
}
if (res.status === 204) return null; // DELETE returns 204 No Content
return res.json();
}
}
SOQL queries and REST API patterns
Salesforce's REST API exposes CRM data through SOQL (Salesforce Object Query Language) — a SQL-like query language that operates on Salesforce objects. MCP tools use SOQL for read access and the REST API's DML endpoints (/sobjects/{objectType}) for writes. Key distinctions from standard SQL: FROM clause uses the API name of the object (not the UI label); LIKE supports % wildcard but not _; WHERE filters are strongly-typed (date literals like LAST_N_DAYS:7 are built in); and relationship queries use dot notation (Account.Name) rather than JOIN.
// MCP tool: query Salesforce records with SOQL
async function queryRecords(client, soql) {
let url = `/query?q=${encodeURIComponent(soql)}`;
const records = [];
// SOQL results are paginated — follow nextRecordsUrl until done=true
while (url) {
const page = await client.request(url);
records.push(...page.records);
// nextRecordsUrl is a relative path like /services/data/v61.0/query/01gxx...
// Strip the /services/data/v61.0 prefix so client.request() can prepend it
url = page.done ? null : page.nextRecordsUrl.replace(/^\/services\/data\/v[\d.]+/, '');
}
return { totalSize: records.length, records };
}
// MCP tool: create a Salesforce record
async function createRecord(client, objectType, fields) {
const result = await client.request(`/sobjects/${objectType}`, 'POST', fields);
// result: { id: '001xx...', success: true, errors: [] }
if (!result.success) {
throw new Error(`Create ${objectType} failed: ${JSON.stringify(result.errors)}`);
}
return result.id;
}
// MCP tool: update a Salesforce record (PATCH — only send changed fields)
async function updateRecord(client, objectType, recordId, fields) {
// PATCH returns 204 No Content on success
await client.request(`/sobjects/${objectType}/${recordId}`, 'PATCH', fields);
}
// MCP tool: upsert by external ID (idempotent create-or-update)
async function upsertRecord(client, objectType, externalIdField, externalIdValue, fields) {
// PUT /sobjects/{objectType}/{externalIdField}/{externalIdValue}
const result = await client.request(
`/sobjects/${objectType}/${externalIdField}/${encodeURIComponent(externalIdValue)}`,
'PATCH',
fields
);
// 200 = updated existing, 201 = created new, 204 = updated (no body)
return result;
}
// MCP tool: Composite API — batch multiple DML operations in one HTTP call
async function compositeRequest(client, requests) {
// Each request: { method, url (relative), referenceId, body }
// allOrNone=true: roll back all if any single operation fails
const result = await client.request('/composite', 'POST', {
allOrNone: false,
compositeRequest: requests.map(r => ({
method: r.method,
url: `/services/data/${client.apiVersion}${r.url}`,
referenceId: r.referenceId,
body: r.body,
})),
});
return result.compositeResponse;
}
Governor limits — the per-org shared quota
Salesforce enforces governor limits — hard quotas on API usage, SOQL rows, heap size, and CPU time — at the org level across all integrations simultaneously. Unlike typical SaaS rate limits (per API key), governor limits are shared: if another integration in the same org uses 95% of the daily API quota, your MCP tool's calls fail even if your integration individually has never been throttled.
The X-Sfdc-LimitInfo response header on every API call shows current consumption: api-usage=1234/15000. The /limits endpoint gives the full picture across all governor categories.
// Check org governor limits — run proactively to detect quota pressure
async function checkLimits(client) {
const limits = await client.request('/limits');
const daily = limits.DailyApiRequests;
const remaining = daily.Remaining;
const max = daily.Max;
const usedPct = ((max - remaining) / max * 100).toFixed(1);
// Alert thresholds for AliveMCP monitoring:
// Warning: <20% remaining (used 80%+ of daily quota)
// Critical: <5% remaining
const status =
remaining / max < 0.05 ? 'critical' :
remaining / max < 0.20 ? 'warning' :
'ok';
return {
status,
dailyApiRequests: { remaining, max, usedPct: parseFloat(usedPct) },
// Other useful limits to surface to LLMs:
concurrentApexExecutions: limits.ConcurrentApexExecutions,
dataStorageMB: limits.DataStorageMB,
fileStorageMB: limits.FileStorageMB,
bulkApiBatches: limits.DailyBulkApiBatches,
};
}
// Parse limit info from response header for per-call tracking
function parseLimitHeader(response) {
const header = response.headers.get('X-Sfdc-LimitInfo');
if (!header) return null;
// Format: "api-usage=1234/15000"
const match = header.match(/api-usage=(\d+)\/(\d+)/);
if (!match) return null;
return { used: parseInt(match[1], 10), max: parseInt(match[2], 10) };
}
Health monitoring beyond token validity
A health probe that only validates the OAuth2 token is insufficient for Salesforce. The token can be valid while the org is in maintenance mode, the API version is deprecated, or the org has exhausted its governor limits. A complete probe must verify org accessibility at the REST API level and check limit headroom.
async function probeSalesforce(client) {
const results = await Promise.allSettled([
// 1. Token validity + instance URL reachability
(async () => {
// GET /services/data/ returns the list of available API versions
// This is the lightest possible authenticated read — no quota impact
const { instanceUrl, token } = await client.getToken();
const res = await fetch(`${instanceUrl}/services/data/`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`/services/data/ → ${res.status}`);
const versions = await res.json();
const latest = versions[versions.length - 1]?.version;
return { kind: 'auth', instanceUrl, latestApiVersion: latest };
})(),
// 2. Governor limits — quota headroom
(async () => {
const limitsResult = await checkLimits(client);
if (limitsResult.status === 'critical') {
throw new Error(`DailyApiRequests critically low: ${limitsResult.dailyApiRequests.remaining} remaining`);
}
return { kind: 'limits', ...limitsResult };
})(),
// 3. SOQL query path — verifies database accessibility
(async () => {
const result = await client.request('/query?q=SELECT+Id+FROM+Organization+LIMIT+1');
if (!result.records?.length) throw new Error('Organization query returned no records');
return { kind: 'soql', orgId: result.records[0].Id };
})(),
]);
return {
healthy: results.every(r => r.status === 'fulfilled'),
components: results.map(r =>
r.status === 'fulfilled'
? { ...r.value, ok: true }
: { ok: false, error: r.reason?.message }
),
};
}
Register the limits check with AliveMCP as a separate monitor from process liveness. Set a warning alert when DailyApiRequests.Remaining drops below 20% — at that point the org needs attention before the daily reset (midnight Greenwich Mean Time for most orgs). The quota reset time for your org is in limits.DailyApiRequests metadata but must be confirmed in org settings.
Common integration pitfalls
- Hardcoding the instance URL
- The instance URL is org-specific and can change (Salesforce migrates orgs between pods during maintenance events). Always read it from the token response and cache it with the token. Never hardcode
https://na1.salesforce.comor any other instance URL as a constant in your MCP tool. - Using login.salesforce.com for sandboxes
- Sandbox orgs authenticate at
test.salesforce.com, notlogin.salesforce.com. Sending sandbox credentials to the production login endpoint returns an authentication error. Make the login URL a configuration parameter, not a hardcoded constant. - Ignoring governor limits until they hit zero
- Governor limits are org-wide and shared with every other integration. If a data migration script or another integration exhausts the daily API quota, your MCP tool's calls fail with
REQUEST_LIMIT_EXCEEDED. MonitorDailyApiRequests.Remainingproactively — by the time calls start failing, the quota is already gone. - Using field labels instead of API names in SOQL
- Salesforce objects and fields have two names: a human-readable label (e.g., "Account Name") and an API name (e.g.,
Name). Custom fields always end in__c(e.g.,External_ID__c). SOQL requires API names. Using the label in a query causes aINVALID_FIELDerror that looks like a field does not exist. - Missing allOrNone semantics in Composite API
- The Composite API's
allOrNone: falsemode processes each sub-request independently and returns partial successes. A composite response withhttpStatusCode: 400on one sub-request does not roll back earlier operations in the same batch. Always check each sub-response'shttpStatusCodeindividually whenallOrNoneis false.
Related guides
- MCP tools for HubSpot — Private App tokens, CRM Objects API, deal pipeline, and search
- MCP tools for Zendesk — API token auth, cursor pagination, ticket lifecycle, and rate limits
- MCP tools for Intercom — Access Token auth, Conversation model, search, and webhook validation
- MCP tools for Freshdesk — API key as Basic auth, status/priority codes, and plan-based rate limits
- MCP server health check patterns — composite endpoints and failure classification
- MCP server uptime monitoring — probes, intervals, and alert routing