DEV Community

Cover image for Routing Model Context Protocol (MCP) Server Resource Exhaustion Spikes to Enterprise Technical Account Managers in Slack
InstaChime
InstaChime

Posted on Originally published at instachime.com Fully Autonomous

Routing Model Context Protocol (MCP) Server Resource Exhaustion Spikes to Enterprise Technical Account Managers in Slack

The Model Context Protocol (MCP) is an open standard that lets AI applications such as Claude, ChatGPT, Cursor and VS Code discover and use external tools, data and prompt templates through one common interface. It is built on JSON-RPC 2.0. As enterprise customers scale agent workloads, they run into practical limits: tool definitions that crowd the context window, oversized tool results, timeouts and rate limits.

For Technical Account Managers (TAMs), Solutions Architects and Sales Engineers at a company that operates hosted MCP servers or an MCP gateway for its customers, these failures are worth watching. A customer who repeatedly hits a ceiling is often running real production workloads and may need architectural help, more capacity or a different deployment model.

They are not automatically a sales signal, though. Repeated errors can also come from a runaway agent loop, a retry storm, a badly written tool or a misconfigured client. This guide shows how to capture these failures at a proxy, separate real capacity pressure from noise, and route a short, privacy-conscious alert to the right account team in Slack.


1. Know What You Can (and Can’t) Observe

Much older MCP material no longer matches the current protocol, so start with what has changed.

The failure signals also come from different layers, and only some of them reach an MCP proxy:

Signal Where it appears What a proxy can see
Tool definitions crowding the context window The host application and the model API, not the MCP server Nothing directly. Capture it at your LLM gateway or in client telemetry
Oversized tool results The tool response, then the host’s own limits Response size in bytes, which is a rough proxy for tokens
HTTP errors such as 429, 503 and 504 The HTTP transport Status codes
Tool execution failures A normal JSON-RPC result with isError: true The response body

A. Context Window Pressure

An MCP server advertises each tool with a name, description and JSON schema, and the host loads those definitions into the model’s context. The cost adds up:

The context window is enforced by the host and the model API, not by MCP. Neither the specification nor a generic MCP server emits a “context length exceeded” error, so don’t expect to catch this in an MCP proxy. Instrument the layer that calls the model.

What it may mean: the customer runs many tools or large servers and would benefit from tool-search or deferred loading, tool pruning, or a larger-context model where one is available.

B. Oversized Tool Results

A tool that queries an unpaginated database or returns raw documents can produce a very large result. The limits that matter are mostly set by the host:

Token counts depend on the language and content. One analysis showed 24,000 characters of CJK text counting as 49,964 tokens, so byte-based estimates are only a rough guide.

What it may mean: the tool needs pagination, server-side filtering or summarization. This is usually a design fix rather than a capacity upgrade.

C. Timeouts, Rate Limits and Tool Errors

MCP separates two kinds of failure, as one error-handling guide explains. A JSON-RPC error is for an invalid request. A normal result with isError: true is for a valid tool call that did not succeed.

  • JSON-RPC errors such as -32600 (invalid request), -32601 (method not found) and -32602 (invalid params) point to client bugs. They are not capacity problems, so don’t alert on them.
  • Rate limits and timeouts usually show up as HTTP statuses (429, 503, 504) or as error text inside an isError: true result.
  • On the model side, the Anthropic API returns 429 rate_limit_error when an account hits a limit and 529 overloaded_error when the API is temporarily overloaded across all users. The 529 is not a capacity signal about your customer.
  • Errors can also arrive mid-stream. The same documentation notes that when streaming over SSE, an error can occur after a 200 response has been returned.

What it may mean: the customer is running production workloads through shared or rate-limited endpoints and may need higher limits or dedicated capacity. Rule out a retry storm first.


2. Architecture: An Observing Proxy

Put a reverse proxy in front of your hosted MCP endpoints. It should observe traffic without altering it:

 [ MCP client / agent host ]
            |  HTTPS (Streamable HTTP: POST, JSON or SSE response)
            v
 +--------------------------------------+
 |  MCP edge proxy                      |
 |  - identifies the account            |
 |  - tees the response (no buffering)  |
 |  - classifies failures               |
 +--------------------------------------+
        |                       |
        v                       v
 [ Hosted MCP server ]   [ Classifier + windowing ]
                                |
                                v
                     [ Slack incoming webhook ]
Enter fullscreen mode Exit fullscreen mode

Four design points:

  1. Don’t buffer streams. Streamable HTTP can return an SSE stream. A middleware that waits for the full response, such as http-proxy-middleware’s responseInterceptor, waits until the whole body arrives, which stalls streaming responses and holds memory. The example below attaches a read-only listener to the response stream instead.
  2. Use headers where available. Under the 2026-07-28 revision, every request names its protocol version in _meta, and on HTTP also in the MCP-Protocol-Version header. Intermediaries can read headers without parsing JSON.
  3. Alert on patterns, not single events. One 429 is noise. Several in a short window is a signal.
  4. Keep payloads out of alerts. Tool arguments and results can contain sensitive customer data. Send classifications and counts, not content.

3. Designing the Slack Alert

A good alert is short, specific and routed to the account’s own channel. Include:

  1. Account: name, ID and tier.
  2. Failure category: rate limit, large payload, timeout, or upstream unavailable.
  3. Velocity: occurrences in a rolling window (for example, 5 in 10 minutes).
  4. Impacted tool: the MCP tool name from the request.
  5. Size metrics: response bytes and a rough token estimate, labelled as an estimate.
  6. A link to the dashboard where the TAM can look at the underlying telemetry.

Two Slack details matter for implementation:

  • Incoming webhooks can post Block Kit layouts. Interactive buttons that send a callback need a Slack app with Interactivity enabled and a Request URL. A plain link in a section block avoids that requirement.
  • To notify a person, use their Slack member ID (<@U012ABCDE>), not a handle like @alex_tam.

Webhooks are also rate limited. Slack returns HTTP 429 with a Retry-After header when limits are exceeded, so dedupe alerts as the code below does.


4. Reference Implementation

This Node.js/TypeScript example uses Express and http-proxy-middleware v3 or later. It forwards traffic unchanged, observes responses, classifies failures, applies a sliding window plus a cooldown, and posts a Block Kit message to Slack. Treat it as a starting point and adapt the thresholds to your own traffic.

// mcp_slack_proxy.ts
import express from 'express';
import axios from 'axios';
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';

interface AccountContext {
  accountId: string;
  accountName: string;
  tier: string;
  tamSlackId: string; // Slack member ID, e.g. "U012ABCDE"
  slackWebhookUrl: string;
}

// Replace with a real datastore. Keep webhook URLs in a secrets manager.
const ACCOUNT_DB: Record<string, AccountContext> = {
  org_acme_corp: {
    accountId: 'org_acme_corp',
    accountName: 'Acme Corporation',
    tier: 'Enterprise',
    tamSlackId: 'U012ABCDE',
    slackWebhookUrl: process.env.ACME_SLACK_WEBHOOK_URL ?? '',
  },
};

type SignalType =
  | 'rate_limited'
  | 'payload_too_large'
  | 'upstream_unavailable'
  | 'upstream_timeout'
  | 'tool_rate_limited'
  | 'tool_timeout'
  | 'large_payload';

interface Signal {
  type: SignalType;
  detail: string;
}

// Tunable heuristics, not standards.
const WINDOW_MS = 5 * 60_000;          // rolling window
const MIN_EVENTS = 3;                  // events in window before alerting
const COOLDOWN_MS = 15 * 60_000;       // per account + signal type
const LARGE_PAYLOAD_BYTES = 100_000;   // ~25k tokens at ~4 bytes/token (rough, English text)
const MAX_INSPECT_BYTES = 1_000_000;   // never buffer more than this for inspection

const events = new Map<string, number[]>();
const lastAlert = new Map<string, number>();

function recordEvent(accountId: string, type: SignalType, now = Date.now()) {
  const key = `${accountId}:${type}`;
  const recent = (events.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
  recent.push(now);
  events.set(key, recent);

  const cooledDown = now - (lastAlert.get(key) ?? 0) >= COOLDOWN_MS;
  const fire = recent.length >= MIN_EVENTS && cooledDown;
  if (fire) lastAlert.set(key, now);
  return { fire, count: recent.length };
}

function tryParse(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return undefined;
  }
}

// Handles both plain JSON responses and SSE ("data: {...}") responses.
function parseMessages(raw: string, contentType: string): any[] {
  if (contentType.includes('text/event-stream')) {
    return raw
      .split('\n')
      .filter((line) => line.startsWith('data:'))
      .map((line) => tryParse(line.slice(5).trim()))
      .filter((m) => m !== undefined);
  }
  const parsed = tryParse(raw);
  if (parsed === undefined) return [];
  return Array.isArray(parsed) ? parsed : [parsed];
}

function classify(status: number, totalBytes: number, messages: any[]): Signal | null {
  if (status === 429) return { type: 'rate_limited', detail: 'Upstream returned HTTP 429.' };
  if (status === 413) return { type: 'payload_too_large', detail: 'Upstream returned HTTP 413.' };
  if (status === 503) return { type: 'upstream_unavailable', detail: 'Upstream returned HTTP 503.' };
  if (status === 504) return { type: 'upstream_timeout', detail: 'Upstream returned HTTP 504.' };

  for (const m of messages) {
    if (m?.result?.isError) {
      // Heuristic text match. Tool error text is free-form and server-defined.
      const text = String(m.result.content?.[0]?.text ?? '').toLowerCase();
      if (/rate.?limit|\b429\b/.test(text)) {
        return { type: 'tool_rate_limited', detail: 'Tool reported a rate limit.' };
      }
      if (/time(d)?[ -]?out/.test(text)) {
        return { type: 'tool_timeout', detail: 'Tool reported a timeout.' };
      }
    }
  }

  if (totalBytes >= LARGE_PAYLOAD_BYTES) {
    return { type: 'large_payload', detail: 'Tool result is large enough to risk host output caps.' };
  }
  return null;
}

async function sendSlackSignal(
  account: AccountContext,
  signal: Signal,
  toolName: string,
  count: number,
  totalBytes: number
) {
  if (!account.slackWebhookUrl) return;

  const message = {
    text: `MCP capacity signal: ${account.accountName} (${signal.type})`,
    blocks: [
      {
        type: 'header',
        text: { type: 'plain_text', text: 'MCP capacity signal', emoji: true },
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Account:* ${account.accountName} (\`${account.accountId}\`)` },
          { type: 'mrkdwn', text: `*Tier:* ${account.tier}` },
          { type: 'mrkdwn', text: `*TAM:* <@${account.tamSlackId}>` },
          { type: 'mrkdwn', text: `*Signal:* \`${signal.type}\`` },
        ],
      },
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text:
            `*Tool:* \`${toolName}\`\n*Detail:* ${signal.detail}\n` +
            `*Occurrences:* ${count} in the last ${WINDOW_MS / 60_000} min`,
        },
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Response size:* ${(totalBytes / 1024).toFixed(1)} KB` },
          { type: 'mrkdwn', text: `*Est. tokens (rough):* ~${Math.round(totalBytes / 4).toLocaleString()}` },
        ],
      },
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `<https://telemetry.example.com/accounts/${account.accountId}|Open telemetry dashboard>`,
        },
      },
    ],
  };

  try {
    await axios.post(account.slackWebhookUrl, message, { timeout: 5000 });
  } catch (err) {
    console.error('Failed to post Slack alert:', err);
  }
}

const app = express();

// Parse JSON only to read the tool name; fixRequestBody re-sends the body upstream.
app.use('/mcp-proxy', express.json({ limit: '5mb' }));

app.use(
  createProxyMiddleware({
    pathFilter: '/mcp-proxy',
    pathRewrite: { '^/mcp-proxy': '' },
    target: 'https://mcp-internal.example.com',
    changeOrigin: true,
    on: {
      proxyReq: (proxyReq, req) => {
        // Ask for an uncompressed response so the observer can read it.
        proxyReq.removeHeader('accept-encoding');
        fixRequestBody(proxyReq, req as express.Request);
      },
      proxyRes: (proxyRes, req) => {
        const header = req.headers['x-enterprise-account-id'];
        const account = typeof header === 'string' ? ACCOUNT_DB[header] : undefined;
        if (!account) return; // Unknown account: forward traffic, but never alert.

        const status = proxyRes.statusCode ?? 0;
        const contentType = String(proxyRes.headers['content-type'] ?? '');
        const toolName = (req as express.Request).body?.params?.name ?? 'n/a';

        // Read-only listener: the response is still streamed to the client untouched.
        const chunks: Buffer[] = [];
        let totalBytes = 0;
        let keptBytes = 0;
        proxyRes.on('data', (chunk: Buffer) => {
          totalBytes += chunk.length;
          if (keptBytes < MAX_INSPECT_BYTES) {
            chunks.push(chunk);
            keptBytes += chunk.length;
          }
        });
        proxyRes.on('end', () => {
          const messages = parseMessages(Buffer.concat(chunks).toString('utf8'), contentType);
          const signal = classify(status, totalBytes, messages);
          if (!signal) return;

          const { fire, count } = recordEvent(account.accountId, signal.type);
          if (fire) void sendSlackSignal(account, signal, toolName, count, totalBytes);
        });
      },
    },
  })
);

app.listen(3000, () => console.log('MCP telemetry proxy listening on :3000'));
Enter fullscreen mode Exit fullscreen mode

Production notes:

  • Authenticate the account from a verified token, not only from a client-supplied header as this demo does.
  • Keep the in-memory maps for a single instance only. Use Redis or similar when you run more than one.
  • Log classifications and counts, not tool arguments or results.
  • If a request is a batch or an SSE stream with many messages, the classifier reports only the first match. Extend it if you need per-message accounting.

5. Playbooks: Fix the Customer’s Problem First

When an alert lands, confirm what is happening before pitching anything. A good TAM response often reduces the customer’s cost first and expands the account second.

Playbook A: Context Pressure

Playbook B: Oversized Results

  • Check: which tool produced the large result and whether it paginates or filters.
  • Help first: add pagination parameters such as offset and limit, server-side filtering, or summarization. Server authors using Claude Code can also set a per-tool result size hint, and customers can raise MAX_MCP_OUTPUT_TOKENS if a larger result is justified.
  • Then consider: higher-throughput or dedicated endpoints if the workload is legitimately heavy.

Playbook C: Rate Limits and Timeouts

  • Check: whether the pattern looks like a retry storm, a loop or genuine growth. Clients should retry 429s with exponential backoff.
  • Help first: adjust limits or timeouts where your policy allows, and suggest request batching or caching.
  • Then consider: dedicated capacity or an SLA-backed deployment if sustained demand exceeds what shared endpoints should serve.

6. Guardrails and Metrics

Guardrails

  • Privacy. Review your contracts and data-processing terms before sending customer telemetry to an internal Slack channel. Restrict channel membership to people who need it.
  • False positives. Use thresholds and cooldowns, and review them regularly. Treat a signal as a reason to start a conversation, not as evidence of purchase intent.
  • Tone. Reach out to help. A customer who hears “we noticed you hit a limit, here is how to fix it” is more receptive than one who feels monitored.

Metrics to track

No public benchmarks exist for this workflow, so measure your own baseline and compare:

  • Time from first alert to first human response
  • Share of alerts that led to a customer conversation
  • Share of alerts classified as false positives (retry storms, bugs)
  • Support tickets avoided or resolved faster for alerted accounts
  • Expansion or upgrade outcomes tied to alerted accounts, tracked separately from fixes that required no purchase

Conclusion

MCP failures do carry information about how customers use your platform. To use that information well, instrument the right layer: the host or model gateway for context pressure, and the HTTP proxy for sizes, status codes and tool errors. Apply thresholds so single blips stay quiet, and keep the alert free of customer content. Lead with fixes that help the customer, and bring up expansion when the data shows demand that the current setup can’t serve.


Sources


Originally published at https://instachime.com/blog/routing-model-context-protocol-mcp-server-resource-exhaustion-spikes-to-enterprise-technical-account-managers-in-slack

Top comments (0)