Building an MCP server that doesn't break when called from automation

Automation Engineering · By Caleb Sakala · April 2, 2026

Frantic cartoon construction worker building a bridge while cars already drive across it

Every MCP server tutorial covers the same ground: install the SDK, define a few tools, register with Claude Desktop, watch the demo work. That setup gets you to a working prototype in under an hour. The problem is that most useful MCP servers end up running inside automation pipelines, not in a local chat window, and the gap between those two contexts breaks things that worked fine in the demo.

Here's what production use requires that the tutorials skip.

Local vs remote transport: the first choice every MCP server build gets wrong

The default transport for most tutorials is stdio. The server runs as a child process on the same machine, communicates through standard input/output, and registers with a host application like Claude Desktop. For personal tools, this works fine.

Automation systems cannot call stdio servers. When a workflow engine, a CI pipeline, or a multi-step automation platform calls your MCP server, it runs on separate infrastructure entirely. Remote transport using streamable HTTP (or Server-Sent Events for older clients) is what production automation actually needs.

The TypeScript SDK exposes this cleanly through @modelcontextprotocol/sdk:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

Switching from stdio to remote mid-project is a significant refactor. The tool definitions themselves stay the same, but session management and request routing change completely. If your server will ever be called by anything outside a local host application, remote transport is the right starting point.

Tool schema design that survives LLM interpretation

This is where most MCP servers fail silently in automation contexts. The LLM calling your tools uses the input schema to construct arguments. Loose schemas produce wrong calls; wrong calls either fail validation or return garbage and succeed anyway.

Every input parameter that represents a constrained value needs an explicit type, a description explaining what the value should contain, and enum or regex constraints where the valid input set is bounded.

Here's the contrast for a tool that fetches customer records:

// Loose — generates wrong calls
customerId: z.string()

// Precise — guides the model correctly
customerId: z.string()
  .regex(/^cust_[a-z0-9]{16}$/)
  .describe("Customer ID from CRM. Format: cust_ followed by 16 lowercase alphanumeric chars. Found under Account > ID.")

The regex alone won't catch every hallucinated ID. The description does the heavier work, narrowing the model's interpretation before a call is even constructed. In an interactive chat session, a user corrects bad IDs. In an automation writing records to a production database, there's no one watching.

How your MCP server handles errors shapes the whole workflow

Tool errors in MCP don't crash the host process. The SDK catches exceptions and returns structured error responses. The LLM receives the error message and decides whether to retry, ask for clarification, or proceed without the tool's output.

Default error handling creates a problem for automation orchestrators: "tool returned an error" and "tool returned no results" look identical from the workflow layer's perspective. Both appear as valid completions. An invoice automation that hits a pricing API timeout and silently marks the invoice complete is a data integrity failure.

Structuring errors explicitly solves this:

return {
  content: [{
    type: "text",
    text: JSON.stringify({
      status: "error",
      error_code: "TIMEOUT",
      message: "Pricing service did not respond within 5 seconds",
      retryable: true
    })
  }],
  isError: true
}

A procurement workflow with a supplier lookup step that returns retryable: true on timeout can pause and retry rather than proceeding with stale pricing data. Teams running procurement automations on Chase Agents use this pattern specifically because the retry routing sits at the workflow level, not duplicated inside each MCP tool: whether the caller retries once, escalates to a human approval queue on failure, or logs and continues is a workflow configuration, not a tool rewrite. The same tool code works across all three behaviors.

Authentication when the caller isn't you

Local stdio servers inherit local credentials. Remote servers need explicit auth, and this is where a lot of internal MCP servers ship with a critical gap.

OAuth 2.1 is now part of the MCP spec for remote servers, but it's overkill for most internal automation use cases. Bearer tokens validated against a secrets store, scoped to specific tool namespaces, handle the vast majority of real-world requirements with far less infrastructure to maintain.

Two design rules matter more than the specific auth mechanism chosen.

Auth validation belongs at the transport layer, not inside individual tool handlers. Middleware that validates every incoming request means each tool doesn't need to replicate the check, and there's no path to an accidentally unguarded tool call.

Token scope should match tool grouping. When a purchasing automation needs both a supplier lookup and a payment initiation step, on Chase Agents the workflow's action-type routing assigns each step its own authorization scope: the lookup step's token can't initiate payments, even when both tools live on the same MCP server. That boundary sits at the workflow configuration level, where it can be audited and changed without touching tool code.

Keep the server stateless

The hardest production problem with MCP servers in automation contexts is shared state between tool calls.

If the server stores context between invocations, two concurrent automation runs will corrupt each other's state. Each tool invocation should be self-contained: receive all required context in its arguments and return a complete response. Any state that needs to persist across steps belongs in the workflow's context object, not in the server process.

This is a genuine hard constraint. Violations produce bugs that only surface under concurrent load and reproduce poorly in local development, which puts them among the most expensive to diagnose after the fact. The concurrency problem also won't appear in any tutorial, because tutorials test with one request at a time.

By end of 2026, most major SaaS platforms will ship official remote MCP servers for their public APIs. When that happens, the engineering problem shifts from "how do I wrap this API in an MCP tool" to "how do I manage authorization boundaries across twenty MCP servers running in the same workflow." Stateless, well-scoped servers built now will compose without a rewrite. Servers that skipped these considerations will either get rewritten or become the production incident that motivates it.