SaaS Integration · 2026-07-03 · SaaS Integration Patterns arc
Building MCP Servers for SaaS APIs: The Four Patterns That Apply to Stripe, Notion, GitHub, Jira, and Google Calendar
When you start wrapping a second SaaS API in MCP tools, you notice something: every integration goes through the same four walls. The auth setup that worked for one API needs rethinking for the next. The rate limits are different numbers but the same problem. The error handling requires the same decision — isError: true or throw? — and makes it correctly one API at a time. And every event-driven SaaS wants to push webhooks at you while MCP tools can only pull. This synthesis covers all five major SaaS integrations — Stripe, Notion, GitHub, Jira, and Google Calendar — through the four patterns they all share, so you recognize them the moment they show up in the sixth integration.
TL;DR
Five different APIs, four shared challenges. (1) Auth: Stripe and Jira use long-lived API tokens (simple); Notion, GitHub, and Google Calendar use OAuth2 with refresh tokens (requires token storage + refresh logic per user). Per-user SaaS MCP servers create one SDK client per request using the user's token, never sharing a module-level client across users. (2) Rate limits: Notion is the tightest at 3 req/s; GitHub has two rate limit systems (primary 5,000/hr + secondary concurrency limits); Stripe auto-retries if you set maxNetworkRetries. Always expose retry_after_ms in rate-limit error payloads. (3) Error mapping: every typed API error (Stripe's StripeCardError, GitHub's RequestError, Notion's APIResponseError, Jira's dual error format) maps to isError: true if the LLM can act on it, throw if it can't. (4) Webhooks: SaaS pushes events; MCP tools pull. The solution is always the same: store events in a local SQLite table when they arrive, expose a polling tool. Wire AliveMCP to monitor your MCP server's protocol health independently of each SaaS service's status page.
Pattern 1 — Auth token management
The first decision in any SaaS integration is auth. Get it wrong here and you either end up with a single-tenant server that only works for one account, or you leak tokens across user sessions. The five APIs split cleanly into two camps:
| API | Auth type | Expiry | Multi-user approach |
|---|---|---|---|
| Stripe | Secret key (API token) | Never (until revoked) | Stripe Connect — per-request client with stripeAccount header |
| Jira Cloud | API token (HTTP Basic) | Never (user-managed) | Per-user token stored in your database, per-request client |
| Notion | Integration token or OAuth2 | OAuth2 tokens don't expire unless revoked | OAuth2 access token per workspace, stored per user |
| GitHub | PAT or GitHub App | PAT: up to 1 year; App installation token: 1 hour | GitHub App with per-installation token refresh |
| Google Calendar | OAuth2 (access + refresh token) | Access token: 1 hour; refresh token: until revoked | Per-user refresh token, auto-refreshed via tokens event |
API token pattern (Stripe, Jira): simplest, but multi-tenant requires care
API tokens are static credentials — they don't expire and don't require refresh logic. The trap with API tokens in multi-tenant MCP servers is accidentally sharing a module-level client across users:
// ✗ WRONG for multi-tenant — one Stripe client serves all users
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-12-18.acacia" });
// ✓ CORRECT for Stripe Connect — per-request client scoped to the user's connected account
function stripeForAccount(accountId: string) {
return new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
stripeAccount: accountId, // routes all API calls to this connected account
});
}
Jira follows the same pattern but with HTTP Basic Auth credentials stored per user in your database:
function jiraAuthHeader(email: string, apiToken: string): string {
return "Basic " + Buffer.from(`${email}:${apiToken}`).toString("base64");
}
// Pass this header into every request constructed for that user's session
OAuth2 refresh token pattern (Notion, GitHub, Google Calendar): the three things that go wrong
OAuth2 adds three problems that pure API token auth avoids: the access token expires, the refresh token sometimes changes, and the token refresh call can race with concurrent requests.
Problem 1 — Access token expiry. Google Calendar access tokens expire after one hour. The googleapis client handles this automatically if you set the refresh token and listen to the tokens event to persist the new access token:
oauth2Client.setCredentials({ refresh_token: storedRefreshToken });
oauth2Client.on("tokens", (tokens) => {
if (tokens.refresh_token) saveRefreshToken(tokens.refresh_token);
saveAccessToken(tokens.access_token!, tokens.expiry_date!);
});
// From here, the client refreshes automatically — no manual refresh calls needed
Problem 2 — Refresh token rotation. Notion access tokens don't expire, but their OAuth2 flow issues a single token at authorization time — there's no refresh. GitHub App installation tokens expire every hour and must be re-requested using the App's private key. Store the App private key as an environment variable (not in the database), generate installation tokens on demand, and cache them per installation until expires_at - 60s.
Problem 3 — Race conditions on refresh. When multiple concurrent MCP tool calls all find an expired access token, they each try to refresh simultaneously, burning rate limit budget and sometimes triggering token revocation. The fix is a per-user refresh lock:
const refreshLocks = new Map<string, Promise<string>>();
async function getAccessToken(userId: string): Promise<string> {
const existing = await db.get("SELECT * FROM tokens WHERE user_id = ?", userId);
if (existing && existing.expires_at > Date.now() + 60_000) return existing.access_token;
// Only one refresh per user at a time
const pending = refreshLocks.get(userId);
if (pending) return pending;
const refreshPromise = doTokenRefresh(userId).finally(() => refreshLocks.delete(userId));
refreshLocks.set(userId, refreshPromise);
return refreshPromise;
}
This pattern applies regardless of which API's OAuth2 tokens you're refreshing — it's the same code for Google Calendar, Notion, and GitHub App installations. Extract it into a shared TokenManager utility rather than reimplementing it per integration.
Pattern 2 — Rate limit handling
Every SaaS API has rate limits. The limits differ, but the MCP-specific problems are identical: an agent calling a tool in a loop will hit them; the error needs to carry enough information for the LLM to back off; and retry logic must be idempotent. Here's where the five APIs land:
| API | Primary limit | Secondary limit | SDK auto-retry? |
|---|---|---|---|
| Stripe | 100 read + 100 write req/s per account | None published | Yes — maxNetworkRetries: 2 |
| Notion | 3 req/s per integration | None | No — manual queue required |
| GitHub | 5,000 req/hr (PAT/App token) | Per-minute concurrency on mutations | Partial — @octokit/plugin-throttling |
| Jira Cloud | Not published; burst-limited per user | None published | No — manual retry required |
| Google Calendar | 1M queries/day; 500 req/100s/user | None | Partial — googleapis client does not auto-retry |
Notion's 3 req/s limit: the tightest in the group
Notion's limit is strict and well-enforced. An agent querying a large database, then creating pages, then appending blocks will hit it in seconds. The correct fix is a token bucket queue that serializes requests to stay under 3 req/s:
class NotionRateLimiter {
private queue: Array<() => void> = [];
private tokens = 3;
private readonly maxTokens = 3;
constructor() {
// Refill 3 tokens per second
setInterval(() => {
this.tokens = Math.min(this.maxTokens, this.tokens + 3);
this.drain();
}, 1000);
}
async request<T>(fn: () => Promise<T>): Promise<T> {
await new Promise<void>(resolve => {
if (this.tokens > 0) {
this.tokens--;
resolve();
} else {
this.queue.push(resolve);
}
});
return fn();
}
private drain() {
while (this.tokens > 0 && this.queue.length > 0) {
this.tokens--;
this.queue.shift()!();
}
}
}
const notionLimiter = new NotionRateLimiter();
// Wrap every Notion API call: notionLimiter.request(() => notion.databases.query(...))
GitHub's two rate limit systems: primary and secondary
GitHub has two completely separate limits that behave differently and require different handling. The primary limit (5,000 req/hr per token) is tracked in response headers and gives you precise visibility into remaining quota. The secondary limits apply to mutation-heavy patterns: creating issues, posting PR comments, or any operation that modifies shared state at high concurrency — they return HTTP 403 with a Retry-After header rather than 429.
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
throttle: {
onRateLimit: (retryAfter, options, _octokit, retryCount) => {
// Primary rate limit — safe to retry after delay
return retryCount < 3;
},
onSecondaryRateLimit: (retryAfter, options) => {
// Secondary rate limit — always retry, Retry-After is the signal
return true;
},
},
});
// Requires: npm install @octokit/plugin-throttling
// Then: const Octokit = Octokit.plugin(throttling);
The key distinction: if your GitHub MCP tool suddenly starts getting 403s on issue creation, it's a secondary rate limit — not an auth failure. The error response body says "You have exceeded a secondary rate limit" rather than the primary limit's message. Without the throttle plugin, these errors surface to the LLM as opaque failures.
The universal rate limit error contract
Regardless of which API triggers a rate limit, your MCP tool error response should carry the same shape so the LLM can act on it consistently:
function rateLimitError(retryAfterMs: number): CallToolResult {
return {
isError: true,
content: [{
type: "text",
text: JSON.stringify({
error: "rate_limited",
retry_after_ms: retryAfterMs,
retryable: true,
message: `Rate limit reached. Retry after ${Math.ceil(retryAfterMs / 1000)}s.`,
}),
}],
};
}
The retryable: true flag is important: it distinguishes a transient rate limit (retry when the window resets) from a permanent error like an invalid API key (which the LLM should not retry). The retry_after_ms gives the LLM a precise wait duration rather than forcing it to guess.
Pattern 3 — Error mapping
Every SaaS API throws typed errors. Every MCP tool handler faces the same decision: should this error surface to the LLM as isError: true, or should it throw and let the MCP SDK turn it into a protocol-level error?
The rule: use isError: true for errors the LLM can act on; use throw for errors the LLM cannot fix. A card decline is isError: true — the LLM can ask the user for a different card. An invalid API key is a throw — the LLM cannot fix a misconfigured credential at runtime, and you want it to propagate as a hard failure rather than a soft tool error.
Stripe's typed error hierarchy
Stripe has the most granular error type system of the five APIs — each error class maps clearly to either LLM-actionable or not:
| Error class | Response | LLM action |
|---|---|---|
StripeCardError | isError: true | Ask user for different payment method |
StripeInvalidRequestError | isError: true | Fix the tool argument (wrong ID format, missing required field) |
StripeRateLimitError | isError: true | Wait and retry |
StripeAuthenticationError | throw | Configuration error — escalate, don't retry |
StripeConnectionError | isError: true | Retry with backoff |
function stripeErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof Stripe.errors.StripeCardError) {
return { isError: true, content: [{ type: "text",
text: `Card declined: ${err.message} (code: ${err.code})` }] };
}
if (err instanceof Stripe.errors.StripeInvalidRequestError) {
return { isError: true, content: [{ type: "text",
text: `Invalid request: ${err.message} (param: ${err.param ?? "unknown"})` }] };
}
if (err instanceof Stripe.errors.StripeAuthenticationError) {
throw err; // Can't fix at runtime — propagate as protocol error
}
if (err instanceof Stripe.errors.StripeError) {
return { isError: true, content: [{ type: "text",
text: `Stripe error (${err.type}): ${err.message}` }] };
}
throw err; // Non-Stripe errors are always unexpected
}
GitHub's RequestError: status code determines actionability
Octokit throws RequestError for all API errors. The HTTP status code determines whether the error is LLM-actionable:
import { RequestError } from "@octokit/request-error";
function githubErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof RequestError) {
if (err.status === 404) {
return { isError: true, content: [{ type: "text",
text: `Not found: ${err.message}. Check the repository owner, name, and that the resource exists.` }] };
}
if (err.status === 403) {
// Could be permission error OR secondary rate limit
const isRateLimit = err.message.includes("secondary rate limit");
if (isRateLimit) {
return { isError: true, content: [{ type: "text",
text: "GitHub secondary rate limit hit — wait 60s before retrying mutation tools" }] };
}
return { isError: true, content: [{ type: "text",
text: `Permission denied: ${err.message}. The token may lack the required scope.` }] };
}
if (err.status === 422) {
return { isError: true, content: [{ type: "text",
text: `Validation failed: ${err.message}` }] };
}
if (err.status >= 500) {
throw err; // GitHub server error — not LLM-fixable
}
return { isError: true, content: [{ type: "text", text: err.message }] };
}
throw err;
}
Notion's APIResponseError: look at the error code, not the HTTP status
Notion's SDK throws APIResponseError for all non-2xx responses. The code field is more useful than the HTTP status for deciding what the LLM should do:
import { APIResponseError } from "@notionhq/client";
function notionErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof APIResponseError) {
if (err.code === "object_not_found") {
return { isError: true, content: [{ type: "text",
text: `Notion page or database not found. Make sure the integration has been shared with this resource in the Notion UI.` }] };
}
if (err.code === "restricted_resource") {
return { isError: true, content: [{ type: "text",
text: `Access restricted. Share the Notion page or database with the integration at notion.so.` }] };
}
if (err.code === "rate_limited") {
return { isError: true, content: [{ type: "text",
text: `Notion rate limit (3 req/s). Wait 1 second before retrying.` }] };
}
if (err.code === "unauthorized") {
throw err; // Token invalid — can't fix at runtime
}
return { isError: true, content: [{ type: "text",
text: `Notion error (${err.code}): ${err.message}` }] };
}
throw err;
}
Jira's dual error format: the one that breaks naive handlers
Jira is the outlier: it returns errors in two different formats depending on the operation, and a naive handler that only reads one format will silently drop the other:
// Jira validation error can arrive in EITHER format:
// Format A: { "errorMessages": ["Issue does not exist"], "errors": {} }
// Format B: { "errorMessages": [], "errors": { "summary": "Field required" } }
function jiraErrorMessage(body: unknown): string {
const b = body as { errorMessages?: string[]; errors?: Record<string, string> };
const messages = b.errorMessages ?? [];
const fieldErrors = Object.entries(b.errors ?? {})
.map(([field, msg]) => `${field}: ${msg}`);
return [...messages, ...fieldErrors].join("; ") || "Unknown Jira error";
}
// Use in the request wrapper:
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const msg = jiraErrorMessage(body);
if (response.status === 400 || response.status === 404) {
return { isError: true, content: [{ type: "text", text: msg }] };
}
if (response.status === 401 || response.status === 403) {
throw new Error(`Jira auth error: ${msg}`); // Can't fix at runtime
}
return { isError: true, content: [{ type: "text", text: `Jira error ${response.status}: ${msg}` }] };
}
The dual format exists because Jira uses errorMessages for top-level validation failures and errors for field-specific validation (e.g., a missing required custom field). Your error extractor must read both and merge them — otherwise you'll see "Unknown Jira error" in tool responses when it's actually a fixable validation failure.
Pattern 4 — The webhook-to-polling gap
Three of the five APIs are designed around push-based event delivery: Stripe has webhooks, GitHub has webhooks, and Notion (less so) has change notifications in enterprise plans. The MCP protocol is request-response — an LLM calls a tool and waits for a synchronous result. These two models don't compose.
The naive approach — registering an MCP tool that returns "I'll notify you when the payment completes" — doesn't work: there's no channel for the server to push a notification back to the LLM after the tool call returns. The correct approach is consistent across all three: store events in a local database when they arrive via webhook, expose a polling tool that queries that state.
The event store + polling tool pattern
This pattern has three components: a webhook handler that stores events, an SQLite events table, and an MCP polling tool:
// 1. SQLite table — created once at startup
db.exec(`
CREATE TABLE IF NOT EXISTS saas_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL, -- 'stripe' | 'github'
type TEXT NOT NULL, -- 'payment_intent.succeeded' etc.
resource_id TEXT NOT NULL, -- the ID the MCP tool will query by
data TEXT NOT NULL, -- JSON-serialized event payload
created_at INTEGER NOT NULL
)
`);
// 2. Webhook handler (Express route outside your MCP server)
app.post("/stripe/webhook", async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
const event = stripe.webhooks.constructEvent(
req.body, // raw body — not parsed JSON!
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
await db.run(
"INSERT OR IGNORE INTO saas_events (id, source, type, resource_id, data, created_at) VALUES (?,?,?,?,?,?)",
[event.id, "stripe", event.type, (event.data.object as { id: string }).id,
JSON.stringify(event.data.object), Date.now()]
);
res.sendStatus(200);
});
// 3. MCP polling tool
server.tool(
"get_payment_status",
"Get the current status of a Stripe payment by PaymentIntent ID",
{ payment_intent_id: z.string().startsWith("pi_") },
async ({ payment_intent_id }) => {
const event = await db.get(
"SELECT type, data FROM saas_events WHERE source='stripe' AND resource_id=? ORDER BY created_at DESC LIMIT 1",
[payment_intent_id]
);
if (event) {
const data = JSON.parse(event.data);
return { content: [{ type: "text", text: JSON.stringify({
source: "webhook_cache", status: data.status, event_type: event.type
})}] };
}
// Fallback: query Stripe API directly if no webhook received yet
const intent = await stripe.paymentIntents.retrieve(payment_intent_id);
return { content: [{ type: "text", text: JSON.stringify({
source: "stripe_api", status: intent.status
})}] };
}
);
GitHub: the same pattern for PR and CI events
GitHub webhooks use the same event store pattern. The only difference is signature verification — GitHub uses HMAC-SHA256 rather than Stripe's proprietary scheme:
import crypto from "crypto";
function verifyGitHubSignature(payload: Buffer, signature: string, secret: string): boolean {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
app.post("/github/webhook", async (req, res) => {
const sig = req.headers["x-hub-signature-256"] as string;
if (!verifyGitHubSignature(req.body, sig, process.env.GITHUB_WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = req.headers["x-github-event"] as string;
const payload = JSON.parse(req.body.toString());
// Store pull_request and check_run events for polling tools
if (event === "pull_request" || event === "check_run") {
await db.run(
"INSERT OR REPLACE INTO saas_events (id, source, type, resource_id, data, created_at) VALUES (?,?,?,?,?,?)",
[`${event}-${payload.action}-${payload[event === "pull_request" ? "pull_request" : "check_run"]?.id}`,
"github", `${event}.${payload.action}`,
String(payload.pull_request?.number ?? payload.check_run?.id),
JSON.stringify(payload), Date.now()]
);
}
res.sendStatus(200);
});
Why "just poll the API directly" is not enough
The fallback path in the polling tool (querying Stripe or GitHub directly if no webhook arrived) works, but it doesn't replace the event store. The event store gives you: (1) the event payload exactly as Stripe/GitHub sent it, including fields that may not be present in the retrieve endpoint; (2) the event type, which tells the LLM what happened (a payment succeeded vs was refunded); (3) timestamp ordering for queries like "what happened to this payment in the last 10 minutes". Polling the source API directly only gives you current state, not event history.
Google Calendar doesn't use webhooks for typical MCP use cases (the freebusy and event creation patterns don't involve push events), so no event store is needed — direct API calls are fine. Jira has webhooks for automation but most MCP use cases (JQL search, issue creation, transitions) are synchronous and don't require event-driven state.
API-specific quirks: the one thing that differs per integration
The four patterns above apply to all five APIs. But each API has one quirk that's not shared with the others — the thing you'll only find in that API's documentation if you're looking for it.
Stripe: idempotency keys derived from semantic intent
Stripe's idempotency key system prevents duplicate charges on retry, but only if the key is stable across retries. A timestamp-based key (Date.now()) changes on every retry, defeating the purpose. The correct approach: derive the key from the semantic intent of the operation using a hash of the business-significant parameters:
import crypto from "crypto";
function stripeIdempotencyKey(params: Record<string, unknown>): string {
return crypto
.createHash("sha256")
.update(JSON.stringify(params))
.digest("hex")
.slice(0, 40); // Stripe accepts up to 255 chars; 40 is sufficient
}
// Usage: same logical operation = same key = Stripe deduplicates within 24h
const ikey = stripeIdempotencyKey({ amount_cents, currency, customer_id, description });
await stripe.paymentIntents.create({ amount: amount_cents, currency, customer: customer_id },
{ idempotencyKey: ikey });
If an LLM calls create_payment_intent twice with the same arguments (due to a retry or a confused agent), Stripe returns the original PaymentIntent both times — no duplicate charge.
Notion: rich text is a typed array, not a string
Notion stores all text content as rich text arrays — a typed list of objects where each element has a text value and optional annotations (bold, italic, color, link). This is not a string. When writing block content, you must construct the array; when reading, you must flatten it. Every Notion integration that forgets this either crashes on write or returns [object Object] on read:
// Flatten Notion rich text array to plain string for tool responses
function richTextToString(richText: Array<{ plain_text: string }>): string {
return richText.map(rt => rt.plain_text).join("");
}
// Build rich text array for writes (simple paragraph)
function stringToRichText(text: string): Array<{type: string; text: {content: string}}> {
// Notion max block length is 2000 chars — split if needed
const chunks: string[] = [];
for (let i = 0; i < text.length; i += 2000) chunks.push(text.slice(i, i + 2000));
return chunks.map(chunk => ({ type: "text", text: { content: chunk } }));
}
// In a page creation tool:
await notion.pages.create({
parent: { database_id },
properties: {
Name: { title: stringToRichText(title) },
// ...
},
children: [
{ object: "block", type: "paragraph",
paragraph: { rich_text: stringToRichText(bodyText) } }
],
});
Google Calendar: always use IANA timezone names, not offsets alone
Google Calendar requires events to have both a datetime string (ISO 8601) and an explicit IANA timezone name. If you pass only an offset (2026-07-10T14:00:00-07:00) without the timezone name, Calendar uses the offset but doesn't know the timezone — which means it can't correctly adjust for daylight saving time transitions within the event's recurrence series:
// ✗ Missing timezone name — works today, breaks on DST boundary
{ dateTime: "2026-07-10T14:00:00-07:00" }
// ✓ Always pair with IANA name
{ dateTime: "2026-07-10T14:00:00-07:00", timeZone: "America/Los_Angeles" }
Common IANA names to handle in tool arguments:
| User says | IANA name to use |
|---|---|
| "Pacific time" / "PT" / "PST" / "PDT" | America/Los_Angeles |
| "Eastern time" / "ET" / "EST" / "EDT" | America/New_York |
| "Central time" / "CT" | America/Chicago |
| "Mountain time" / "MT" | America/Denver |
| "London" / "GMT" / "BST" | Europe/London |
| "UTC" / "Z" | UTC |
GitHub: code search is a separate, stricter rate limit
GitHub code search (GET /search/code) has a separate rate limit of 10 requests per minute — much tighter than the primary 5,000/hr limit that applies to most other endpoints. If your MCP server has a search_code tool and an agent uses it in a loop, it will hit this limit within seconds. The correct approach: cap code search results aggressively (10 results maximum), add a tool description that discourages repeated calls, and never call it inside a paginated loop:
server.tool(
"search_github_code",
"Search GitHub for code matching a query. Use this once per task — code search has a strict 10 req/min rate limit. Results are approximate.",
{
query: z.string().describe("Code search query — supports language:, repo:, path: qualifiers"),
per_page: z.number().int().min(1).max(10).default(5)
.describe("Max 10 results — code search rate limit is 10 req/min"),
},
async ({ query, per_page }) => { /* ... */ }
);
Jira: never hardcode transition IDs — always discover them first
Jira workflow transition IDs are workspace-specific — they differ between every Jira instance, and they change when admins modify the workflow. An MCP tool that hardcodes a transition ID will work on the development instance and fail silently on production. The correct pattern: always call the transitions endpoint first to get the available transitions for the specific issue, then use the ID returned:
server.tool(
"transition_jira_issue",
"Move a Jira issue to a new status using its workflow transition",
{
issue_key: z.string().describe("Jira issue key, e.g. PROJ-123"),
target_status: z.string().describe("Target status name, e.g. 'In Progress', 'Done'"),
},
async ({ issue_key, target_status }) => {
// Step 1: Get available transitions for THIS issue in THIS workflow
const transitions = await jiraRequest<{transitions: Array<{id: string; name: string}>}>(
"GET", `/issue/${issue_key}/transitions`
);
const match = transitions.transitions.find(t =>
t.name.toLowerCase() === target_status.toLowerCase()
);
if (!match) {
const available = transitions.transitions.map(t => t.name).join(", ");
return { isError: true, content: [{ type: "text",
text: `Status "${target_status}" not available. Available transitions: ${available}` }] };
}
// Step 2: Execute the transition using the discovered ID
await jiraRequest("POST", `/issue/${issue_key}/transitions`, {
transition: { id: match.id }
});
return { content: [{ type: "text",
text: `Moved ${issue_key} to "${target_status}"` }] };
}
);
This two-step pattern also gives the LLM a useful error when the target status isn't reachable from the current state — it sees the list of available transitions rather than a cryptic 400 response.
Monitoring: why the SaaS status page isn't enough
When your MCP server wraps Stripe, it has two failure surfaces: the MCP server itself (the protocol layer) and Stripe's API (the upstream service). These are independent — and they fail differently.
| Failure | SaaS status page shows | MCP protocol probe shows |
|---|---|---|
| Your MCP server crashes (OOM, unhandled exception) | Green (Stripe is up) | Red (connection_refused) |
| TLS certificate expired on your domain | Green | Red (tls_error) |
| New deploy broke the MCP initialize handshake | Green | Red (protocol_error) |
| API key rotated in prod but not updated in env vars | Green | Yellow (tools return isError:true) |
| Stripe API is down | Red (incident page) | Yellow (tools return isError:true, server is up) |
| GitHub secondary rate limit throttling all mutations | Green | Yellow (tool error rate elevated) |
The pattern: SaaS status pages tell you about the upstream service. An external MCP protocol probe tells you about your server. You need both signals — but the MCP protocol probe is the one that's missing in most setups.
The probe should call your server's initialize endpoint every 60 seconds and verify: (1) the TLS handshake completes, (2) the JSON-RPC response is well-formed, (3) tools/list returns the expected number of tools. This catches every row in the "connection_refused / tls_error / protocol_error" column of the table above. AliveMCP runs this probe for every registered MCP endpoint and surfaces the failure before users notice the tools aren't working.
Additionally, add structured logging to your SaaS tool handlers that tracks the isError: true rate per tool. If create_payment_intent suddenly returns 100% errors, it could be a rotated API key — the MCP protocol probe won't catch this (the server is technically up), but a log alert on the tool-level error rate will.
Putting it together: a SaaS MCP server checklist
For any new SaaS integration, work through these four questions before writing your first tool handler:
- Auth type? API token (Stripe, Jira) → store in env var, create per-request client for multi-tenant. OAuth2 (Notion, GitHub App, Google Calendar) → store refresh token per user, implement token refresh with a per-user lock.
- Rate limits? Check the docs for both primary limits (per hour/day) and secondary limits (per minute, mutation-specific). Notion needs a token bucket queue. GitHub needs the throttle plugin. Add
retry_after_msto all rate limit error payloads. - Error types? Map every typed error to either
isError: true(LLM-actionable) orthrow(configuration failure). Handle Jira's dual error format. Never let a raw exception surface to the LLM as an opaque message. - Event-driven state? If the API sends webhooks, set up the event store + polling tool pattern. Never try to "wait" for an event inside a tool call — return current state and let the agent poll.
The fifth thing: wire an external MCP protocol probe before you ship. The four patterns above make your tools correct. The probe makes sure the tools are reachable.
Further reading
- MCP server Stripe integration — payment tools, idempotency, webhooks, and PCI scope
- MCP server Notion integration — database queries, page creation, block manipulation
- MCP server GitHub API integration — Octokit, rate limits, auth strategies, pagination
- MCP server Jira integration — JQL queries, issue tools, Atlassian OAuth2, custom fields
- MCP server Google Calendar integration — event tools, freebusy, OAuth2, timezones
- MCP server authentication — API keys, OAuth2, and session tokens
- MCP server rate limiting — per-tool limits and backoff patterns
- MCP server webhook patterns — event-driven tools and polling adapters
- MCP Server Rate Limiting: Per-Tool Limits, Client Throttling, Backoff, DDoS Defense, and Quota Management
- MCP server authentication guide — credentials, OAuth2, and session scoping
- AliveMCP — production protocol monitoring for MCP servers