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:

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.com or any other instance URL as a constant in your MCP tool.
Using login.salesforce.com for sandboxes
Sandbox orgs authenticate at test.salesforce.com, not login.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. Monitor DailyApiRequests.Remaining proactively — 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 a INVALID_FIELD error that looks like a field does not exist.
Missing allOrNone semantics in Composite API
The Composite API's allOrNone: false mode processes each sub-request independently and returns partial successes. A composite response with httpStatusCode: 400 on one sub-request does not roll back earlier operations in the same batch. Always check each sub-response's httpStatusCode individually when allOrNone is false.

Related guides