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.