Exam Room · Advanced Generative AI Developer

Choosing Where an MCP Server Runs

· 36 min read

Generative AI Development · part of The Exam Room

The situation

A produce-box subscription business runs a customer-facing agent on the Bedrock AgentCore Runtime, built with Strands, with its tools published through an AgentCore Gateway. Four tools are due this quarter and they have almost nothing in common except the protocol they will be called over.

getSubscription takes a subscriber id and returns one DynamoDB row: plan, delivery day, pause state, substitution preferences. It answers in about forty milliseconds and the agent reaches for it on nearly every turn, so it will run tens of thousands of times a day in a bursty shape that tracks the working morning.

queryDeliveryDensity answers questions about delivery outcomes by postcode over the last two years. It works against an in-memory index built at start-up from Parquet in S3: roughly ninety seconds to build, roughly 400MB resident, and once it is warm a query comes back in a couple of hundred milliseconds. Cold, the same query takes a minute and a half. It gets called perhaps sixty times a day, mostly by the operations team asking the agent about substitution risk.

optimiseRun shells out to a licensed vendor binary that plans a van’s drop order. The binary wants a real filesystem, a road-network data directory that is several gigabytes, and a licence file the vendor renews quarterly. That licence permits four concurrent executions, no more, and a single call runs between twenty and ninety seconds.

The fourth tool is not theirs. The warehouse management vendor ships an MCP server of its own, hosted by the vendor, with tools for stock levels and pick status. The team wants the agent to be able to call it.

The gateway is the same for all four. Where the server behind each one runs is four separate decisions.

What actually matters

Choosing MCP for agent-tool interactions settles a great deal. It settles how a tool advertises its name, description, and input schema; how the agent discovers what is available; how a call and its result are framed; and how errors come back. That uniformity is why tool schema design is a portable skill rather than a per-vendor one. What the protocol does not settle is what kind of process answers on the other end of the connection, and that is the decision still open here. An MCP server is a process. Hosting it means choosing compute for a process, and every ordinary property of compute applies: how long it lives, what it can hold, how long a single request may take, what it costs while nobody is calling.

The property that decides it is what a call needs from the process that came before it. If a tool call is a pure function of its arguments plus whatever sits in a durable store, then any process can serve any call, processes may be created and destroyed freely, and nothing is lost when one goes away. If a tool call depends on something the process built up in its own memory, a warm index, a connection pool, a loaded model, a running session, then it has to reach that same process or rebuild what it needs. Compute that hands you no instance affinity, freezes the environment between invocations so background work stops, and reclaims environments on its own schedule serves the first shape beautifully and the second shape either slowly or wrongly. Nothing about the protocol tells you which shape a tool is. Reading the tool’s implementation does.

Cost shape falls out of the same fact rather than being an independent axis. Per-request billing is close to free for something called rarely and briefly, and stays cheap when called constantly and briefly, because you pay for milliseconds of work and nothing else. It goes bad exactly where the warm-up is expensive: either every call pays the warm-up, or you pay to keep environments initialised, at which point you are funding an always-on process through a mechanism designed for the opposite. An always-on container is the reverse. It bills while idle and that is the cost of having something warm to answer with, which is a fair trade when the warm thing took ninety seconds to build and a bad one when the tool reads a single row.

Two constraints sit underneath all of it. Transport decides reachability: MCP defines a stdio transport, where the client launches the server as a subprocess on the same machine and talks over standard input and output, and a streamable HTTP transport that works across a network. A gateway sits in a different account from your tool code, so only the HTTP transport is hostable behind one; a stdio server has to be wrapped in something that speaks HTTP before anything remote can call it. Execution identity decides blast radius: every tool a server hosts runs under that server’s role, so grouping tools onto one server takes the union of their permissions and hands it to all of them. The subscriber lookup needs one DynamoDB table. The route optimiser needs a licence secret and a road-data bucket. Putting both in one process gives an injected instruction reaching either tool access to both.

What we’ll filter on

  1. State between calls. Does the tool need anything the previous call left in this process, or is every call answerable from arguments plus a durable store?
  2. Warm-up and duration. How long does the process take to become useful, and what is the worst case for a single call?
  3. Idle cost against call volume. Is this called constantly, occasionally, or a few dozen times a day, and what does it cost while nobody is calling?
  4. What the process needs on disk. Custom binaries, licence files, multi-gigabyte data directories, or nothing beyond the language runtime.
  5. Execution identity. Which permissions does this tool need, and which other tools can safely share a role with it.
  6. Who runs it. Does a server for this already exist somewhere, and is registering it cheaper than building one.

The landscape

The options are the ordinary AWS compute catalogue, narrowed by the fact that whatever you pick has to terminate an HTTP connection the gateway can reach. Between them they cover the space of model extension frameworks for a tool of any shape.

A Lambda target on the gateway

The first option skips the server entirely. An AgentCore Gateway takes Lambda functions as targets directly, alongside OpenAPI documents, Smithy models, and existing MCP servers, and publishes all of them to the agent as MCP tools behind one endpoint. The gateway is the MCP server; your Lambda is a function it calls. You write a handler and a schema, and the protocol work, the transport, and the tool listing are the gateway’s problem. Each function carries its own execution role, so per-tool least privilege is the default rather than something to arrange.

Lambda functions as stateless MCP servers

The second option writes a real MCP server and hosts it on Lambda. Using Lambda functions to implement stateless MCP servers that provide lightweight tool access is a distinct pattern from the one above. Reach for it when the server should be callable by more than one gateway, when you want the same code runnable on a laptop, or when a vendor’s server ships as a library you would rather host than rewrite. The function sits behind a function URL or an API Gateway endpoint and speaks streamable HTTP. Response streaming through a function URL keeps long tool results moving rather than buffering to the end.

The constraints are the ones Lambda always has and they are unusually relevant here. Fifteen minutes is the hard ceiling on a single invocation. Memory goes to about ten gigabytes and ephemeral storage under /tmp to the same, and a container image from Amazon ECR can be up to ten gigabytes, which covers a surprising amount of dependency weight. The environment freezes between invocations, so a background refresh thread does not run, and nothing routes a follow-up call back to an environment that already has your index. Provisioned concurrency keeps environments initialised at a price that looks a lot like paying for a container, and SnapStart shortens initialisation for Java, Python, and .NET without giving you affinity.

Amazon ECS with AWS Fargate

Using Amazon ECS to implement MCP servers that provide complex tools is the other half of that pairing, and complex here means the process is doing something across calls rather than the tool being conceptually hard. You build a container image, push it to Amazon ECR, and run it as an ECS service on AWS Fargate so there is no fleet of instances underneath to patch or size. Behind an internal Application Load Balancer it gets a stable address the gateway can reach through PrivateLink. The task definition is where the awkward requirements land: a task role scoped to exactly this server’s tools, memory sized for whatever is held resident, an EFS volume for files too large or too licensed to bake into the image, and a desired count you set rather than one that floats.

Amazon EKS does the same job for a team already running Kubernetes and running one for a tool server is not worth the cluster. Amazon EC2 comes back into play only when something is pinned to a host in a way Fargate cannot express, which for most licensed software it is not.

AWS App Runner

App Runner is ECS with the operational surface removed. Point it at an image in Amazon ECR, give it a port, CPU, and memory, and it returns an HTTPS endpoint and autoscales the container behind it. There is no cluster, no load balancer, no target group, and no listener rule to own. A VPC connector lets the container reach private resources, and health checks gate traffic until the process reports ready, which is how a ninety-second warm-up stays invisible to callers.

What you give up is control over the things ECS exposes. Instance count moves on App Runner’s rules rather than yours, there is no session affinity, and there are no volume mounts or sidecars. It also does not scale to zero: the minimum provisioned instance is billed for its memory around the clock, with CPU charged only while a request is in flight, so the idle bill is real but smaller than a Fargate task’s.

A server that already exists

The last option is to build nothing. A gateway takes an existing MCP server as a target, translates for it, and attaches outbound credentials on the way through, so a vendor-hosted server becomes another tool in the same list the agent already reads. The credentials are the work rather than the hosting: AgentCore Identity holds the client credentials or API key and brokers them per call rather than leaving a standing key in the agent.

Evaluation

Side by side

Option Holds warm state between calls Zero cost while idle No fifteen-minute ceiling Arbitrary binaries and mounted files Own execution role per tool Nothing extra to operate
Lambda target on the gateway
Lambda function as an MCP server
Container on AWS App Runner
Container on Amazon ECS with AWS Fargate
Existing MCP server elsewhere

Three cells deserve their reasoning read out. The warm-state cross on both Lambda rows is not a limit you can engineer around. Provisioned concurrency keeps environments initialised and still routes each call to whichever one is free, so a cache built by one invocation is a cache the next invocation may or may not find. The execution-role cross on the Lambda-as-MCP-server row is a consequence of the shape rather than the service, because one function hosting five tools runs all five under one role, and splitting it into five functions is the Lambda-target row again with extra protocol code. The last row’s ticks are somebody else’s ticks; the operational virtue of a vendor’s server is inseparable from having no say in how it behaves.

The optimiseRun requirements land almost entirely in the fourth column, and it is the only column where App Runner and ECS separate. That is worth noticing before running the gates, because the fourth tool’s constraints are the ones that will not bend.

Which home fits which tool

THE TOOL THE GATES THE HOME Warehouse stock lookup, the vendor runs a server getSubscription, one DynamoDB row per call Delivery-density query, 400MB warm index Route optimiser, licensed binary, ninety-second calls Does a server for this already exist elsewhere? Keeps state, or runs past fifteen minutes? Mounts, a concurrency cap, or a private ALB? Register the vendor server gateway target, brokered creds Lambda target on the gateway per request, free when idle Container on AWS App Runner from ECR, nothing to operate Amazon ECS with AWS Fargate task role, count and volumes yes no, ask the next gate no yes, ask the next gate no yes

Running the gates in that order matters because the cheapest answers are at the top. Asking whether a server already exists before asking anything else avoids building one that a vendor maintains for free. Asking about state before asking about mounts keeps three quarters of the tool catalogue on Lambda, where it belongs.

The solution

getSubscription is a Lambda target on the gateway. Every call is a GetItem against one table, nothing survives from one call to the next, and forty milliseconds of work priced per request against tens of thousands of daily calls is a rounding error. No MCP server code is written for it at all; the gateway publishes the function’s schema as a tool and handles the protocol. The function’s execution role reads one table and nothing else, which is the whole of its blast radius.

queryDeliveryDensity is a container on AWS App Runner, built from an image in Amazon ECR. The index build is the reason it cannot be a Lambda. Paying ninety seconds on a cold environment turns a two-hundred-millisecond tool into one the agent times out on, and provisioned concurrency buys initialised environments with no promise that the one holding the index is the one that gets the call. App Runner suits it because the server needs nothing beyond CPU, memory, and a port: no volumes, no sidecar, no fixed instance count, and the health check holds traffic back until the index reports ready. Sixty calls a day against an always-on container is a bill measured in tens of dollars a month, and the alternative is a tool that is usually cold.

optimiseRun is an ECS service on AWS Fargate, and it earns every part of the extra operational weight. The road-network directory sits on an EFS volume mounted into the task rather than inflating the image, and the licence file arrives from Secrets Manager at start-up. The vendor’s four-concurrent-execution limit becomes a fixed desired count with no autoscaling policy attached, which is a control App Runner does not offer. Ninety-second calls sit comfortably inside a request an internal Application Load Balancer will hold open, and the task role is scoped to the licence secret and the road-data bucket alone, sharing nothing with the other two tools.

The warehouse tools are registered as an MCP-server target on the gateway. The vendor runs the server, the gateway translates for it, and AgentCore Identity holds the client credentials so nothing standing lives in the agent. Building a second server to proxy it would add a hop, a deployment, and a role, and buy nothing.

The client side

All four arrive at the agent identically. Strands consumes gateway tools through its MCP client rather than each agent implementing its own, which is one of the reasons it came out ahead when the team was choosing a framework. MCP client libraries are what give consistent access patterns across a Lambda target, two containers of the team’s own, and a vendor’s server. A second agent added next quarter discovers and calls all four without knowing where any of them runs. The gateway and the client library between them make hosting an implementation detail, which is exactly what makes it safe to decide per tool.

Both container images build in the same pipeline as everything else, so a tool server ships through the pipeline the feature already uses rather than a bespoke path.

Two ways this goes wrong

The first is session state behind a load balancer. Streamable HTTP carries a session identifier in a header, and a server that keeps per-session state in memory needs every request in that session to reach the same task. An ALB’s stickiness is cookie-based, and a protocol client has no reason to honour it. A second task then answers a follow-up call with no memory of the session, and the conversation breaks in a way that reproduces about a third of the time. Two ways out: keep per-session state in DynamoDB or ElastiCache so any task can serve any session, or run one task and accept the availability cost. The design above dodges it because the density index is read-only and rebuilt on start-up, so there is no session to lose.

The second is the convenience server. One MCP server, every team’s tools, one deployment to manage. The role that server runs under becomes the union of every permission any of those tools needs, and the tool that a prompt injection reaches is no longer bounded by what that tool was meant to do. The gateway already gives one endpoint and one tool list to the agent, so consolidating servers buys convenience that was already free and pays for it in blast radius. Split servers on the permission boundary, not on which team wrote the code.

What’s worth remembering

  1. MCP settles how tools are described, discovered, and called; it settles nothing about where the server process runs, and that choice is ordinary compute selection.
  2. What a call needs from the previous call decides the host: keep nothing and it is a Lambda, hold a warm index, a pool, a loaded model, or a session and it is a container, because Lambda gives no instance affinity and freezes between invocations.
  3. The pairing at the heart of model extension frameworks is Lambda functions for stateless MCP servers giving lightweight tool access and Amazon ECS for MCP servers that provide complex tools, with AWS App Runner in the middle for an always-on container needing no mounts, sidecars, or fixed instance count.
  4. Only the streamable HTTP transport is reachable across a network; a stdio server runs as a subprocess of its client and has to be wrapped in something HTTP-shaped before a gateway can reach it.
  5. A stateful server behind a load balancer needs the session to survive landing on a different task, so externalise session state rather than relying on cookie stickiness a protocol client never sends.
  6. One server per permission boundary, because every tool a server hosts runs under that server’s role, and consolidating servers hands each tool the union of the others’ access.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.