SaaS Integrations: Salesforce, Slack, Jira & GitHub via MCP

12 min read Module 5 of 10 Topic 15 of 30

What you'll learn

  • Normalize different SaaS authentication models (OAuth app tokens, personal access tokens) behind one MCP server
  • Implement a shared rate-limit-aware client layer that respects each provider's limits
  • Design MCP tools for Salesforce, Slack, Jira, and GitHub that map to real agent tasks
  • Handle eventual consistency correctly when a write to a SaaS API is not immediately visible in a subsequent read
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Each SaaS system has its own authentication model, rate limits, and API shape, and some have consistency quirks that only surface once you chain a write immediately followed by a read. Wrapping several of them behind one MCP server pays off most when the messy provider-specific details are isolated into per-provider client classes, leaving tool functions themselves uniform and simple.

Normalizing Authentication Behind Client Classes

class SalesforceClient:
    """Handles OAuth app-token refresh transparently; tools never see tokens."""
    def __init__(self):
        self._token: str | None = None
        self._expires_at: float = 0

    async def _ensure_token(self):
        if time.time() >= self._expires_at - 60:  # refresh 60s before expiry
            resp = await httpx.AsyncClient().post(SF_TOKEN_URL, data=SF_CLIENT_CREDS)
            body = resp.json()
            self._token, self._expires_at = body["access_token"], time.time() + body["expires_in"]

    async def search_accounts(self, query: str) -> list[dict]:
        await self._ensure_token()
        resp = await httpx.AsyncClient().get(
            f"{SF_INSTANCE_URL}/services/data/v62.0/query",
            params={"q": f"FIND {{{query}}} RETURNING Account"},
            headers={"Authorization": f"Bearer {self._token}"},
        )
        return resp.json()["searchRecords"]

class GitHubClient:
    """Static PAT, no refresh needed, but still centralizes rate-limit tracking."""
    def __init__(self):
        self._headers = {"Authorization": f"Bearer {os.environ['GITHUB_PAT']}"}

    async def create_issue(self, repo: str, title: str, body: str) -> dict:
        resp = await httpx.AsyncClient().post(
            f"https://api.github.com/repos/{repo}/issues",
            json={"title": title, "body": body},
            headers=self._headers,
        )
        resp.raise_for_status()
        return resp.json()

Shared Rate-Limit-Aware Layer

Since one MCP server may serve many concurrent agents, provider rate limits must be enforced centrally, per provider, not left to each caller.

import asyncio, time

class TokenBucketLimiter:
    """Shared across all tool calls hitting one provider's API."""
    def __init__(self, rate_per_sec: float, burst: int):
        self._tokens = burst
        self._rate = rate_per_sec
        self._burst = burst
        self._last = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self):
        async with self._lock:
            now = time.monotonic()
            self._tokens = min(self._burst, self._tokens + (now - self._last) * self._rate)
            self._last = now
            if self._tokens < 1:
                await asyncio.sleep((1 - self._tokens) / self._rate)
            self._tokens -= 1

slack_limiter = TokenBucketLimiter(rate_per_sec=1, burst=5)  # Slack Tier 2: ~1 req/sec

@mcp.tool(name="slack_post_message")
async def post_message(channel: str, text: str) -> dict:
    """Post a message to a Slack channel. Rate-limited centrally across all callers."""
    await slack_limiter.acquire()
    return await slack_client.post_message(channel, text)
flowchart TD
    A1["Agent A"] --> MCP["MCP Server"]
    A2["Agent B"] --> MCP
    A3["Agent C"] --> MCP
    MCP --> SFC["SalesforceClient\n(token refresh)"]
    MCP --> SLC["SlackClient\n(rate limiter)"]
    MCP --> JRC["JiraClient\n(OAuth)"]
    MCP --> GHC["GitHubClient\n(static PAT)"]
    SFC --> SF["Salesforce API"]
    SLC --> SL["Slack API"]
    JRC --> JR["Jira API"]
    GHC --> GH["GitHub API"]

    style MCP fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style SFC fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style SLC fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style JRC fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style GHC fill:#EEF0F7,stroke:#6366F1,color:#0F172A

Handling Eventual Consistency Across Writes and Reads

A subtlety that only appears once an agent chains a write and a confirmatory read in the same task: several SaaS platforms are eventually consistent for freshly created or updated records, a write succeeds against the system of record, but a search index, read replica, or cache the subsequent read hits has not caught up yet. An agent that creates a Jira ticket and immediately searches for it can get a 404 or an empty search result for a few seconds, entirely correctly, and a naive tool implementation will report this as “the ticket was not created” when in fact it was.

@mcp.tool(name="jira_create_bug")
async def create_bug(project_key: str, summary: str, description: str, severity: str) -> dict:
    """
    Create a bug ticket in Jira. Returns the full created ticket object
    directly, so callers do not need an immediate follow-up read (which
    can race Jira's own search-index propagation delay).
    """
    ticket = await jira_client.create_issue(
        project_key, issue_type="Bug", summary=summary,
        description=description, labels=[f"severity-{severity}"],
    )
    return ticket.model_dump()  # Full object, no need to re-fetch to "confirm" it exists

@mcp.tool(name="jira_get_ticket")
async def get_ticket(ticket_key: str) -> dict:
    """
    Fetch a ticket by key. Retries briefly on a 404, since Jira's search
    index can lag a few seconds behind a very recent create.
    """
    for attempt in range(3):
        try:
            return (await jira_client.get_issue(ticket_key)).model_dump()
        except JiraNotFoundError:
            if attempt == 2:
                return {"isError": True, "retryable": False, "message": f"Ticket '{ticket_key}' not found"}
            await asyncio.sleep(0.5 * (attempt + 1))  # Brief bounded backoff, not an indefinite retry

The two mitigations shown are complementary rather than either/or: designing create_bug to return the complete created object removes most of the actual need for an immediate confirmatory read in typical agent workflows, while get_ticket’s brief bounded retry handles the residual case where a read genuinely does need to happen shortly after a write it did not itself perform (a different agent session looking the ticket up moments later, for instance). Whichever provider is being wrapped, check its documentation specifically for consistency guarantees on the endpoints you are exposing, this is exactly the kind of provider-specific quirk that belongs isolated inside the client class, not leaked into every tool that happens to call it.

Task-Oriented Tools per Provider

@mcp.tool(name="jira_create_bug")
async def create_bug(project_key: str, summary: str, description: str, severity: str) -> dict:
    """Create a bug ticket in Jira. Use for confirmed bugs, not feature requests (use jira_create_story)."""
    return await jira_client.create_issue(project_key, issue_type="Bug", summary=summary,
                                          description=description, labels=[f"severity-{severity}"])

This closes Module 5. With enterprise systems wrapped as tools, Module 6 addresses what happens as this catalog grows across many servers and versions over time: semantic versioning, namespacing, and federation.

Knowledge Check

3 questions to test your understanding

1 A Salesforce integration uses OAuth app tokens that expire hourly, while a GitHub integration uses a long-lived personal access token. Where should this difference be handled in the MCP server?

2 An MCP server wraps Slack's API, which enforces its own per-workspace rate limits. Multiple agents across the company call Slack tools concurrently through this one server. What is the correct place to enforce Slack's rate limit?

3 An agent calls `jira_create_bug`, receives a successful response with a new ticket ID, and immediately calls `jira_get_ticket` with that same ID to confirm it, but gets a 404 for a few seconds before the ticket becomes visible. What is happening, and how should the tool layer handle it?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan