MCP Strategies on AWS, Part 3: Hosting, from Local to AWS
written by Stefan Christoph
- 9 minutes readThe tool-design post decided what your tools are. This one decides where they live. The AWS MCP guidance is candid that most MCP usage today is local, and it lays out a ladder you climb only as far as you need to [1]. All the code in this post (the local server, the CDK stack, the signing client, and the deploy/teardown scripts) is on GitHub.
The hosting ladder. Start local. Move to a remote server bounded to your account when you need auth and lifecycle control. Reach for a managed gateway when you need a single endpoint and context optimization. Internet-facing is a separate, deliberate step.
Rung one: local, where you should start
A local MCP server runs as a subprocess on your machine and talks JSON-RPC over stdio. There is no client-to-server authentication to configure, because there is no network in between: the server runs with your own local credentials and files. That makes it the right place to start and iterate.
With the official SDK it is a few lines:
From local_server.py, a local stdio MCP server:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("mcp-strategies-demo-local")
@mcp.tool()
def word_count(text: str) -> dict:
"""Count words and characters. A read-only tool: it computes, it does not mutate."""
return {"words": len(text.split()), "characters": len(text)}
if __name__ == "__main__":
mcp.run(transport="stdio") # no network, no auth
The guidance is honest about the cost of staying here: you get no lifecycle control. Users self-discover, download, and configure the server, which means some of them run outdated or vulnerable versions, and compliance gets hard. That is the pressure that pushes you up the ladder.
Rung two: remote, bounded to one account
The remote rung is where I wanted to be concrete, because “remote” is where the security model from the governance pillar starts to matter, and where a vague blog post would hand-wave. So I deployed a real one.
The design choice that does the work is the authorizer. I used a Lambda Function URL with AuthType = AWS_IAM. The URL sits on a public hostname, reachable from the internet, but every request must be SigV4-signed by an IAM principal that IAM authorizes to invoke it, for newly created function URLs that means both lambda:InvokeFunctionUrl and lambda:InvokeFunction [5]. AWS_IAM by itself is not an account boundary: a resource-based policy on the function could grant a principal in another account access [5]. This stack grants none, so the only callers IAM will authorize are principals in the deploying account carrying those permissions in their identity policy. An unsigned or unauthorized caller is rejected before the handler runs. The boundary is the complete IAM configuration, the auth type plus a resource policy that grants nothing outside the account, enforced at the edge rather than by anything in my code.
The CDK stack is small:
From cdk/app.py, the account boundary is the auth type:
fn = _lambda.Function(self, "McpServer",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="handler.handler",
code=_lambda.Code.from_asset("lambda"),
environment={"MCP_ACCOUNT_ID": self.account})
# AWS_IAM auth + no cross-account grants = account-bounded. No CORS (not browser-facing).
fn.add_function_url(auth_type=_lambda.FunctionUrlAuthType.AWS_IAM)
The handler is stdlib-only: MCP speaks JSON-RPC 2.0, and this demo handler answers just the three methods the client exercises: initialize, tools/list, and tools/call. That is a deliberate demo subset, not a complete implementation of the MCP Streamable HTTP transport, a production server also owes the spec its initialization sequencing, capability and version negotiation, notifications, and transport behavior. Keeping it dependency-free keeps the deploy small and auditable. The client side is where the signing happens:
From sigv4_client.py, sign every request as the lambda service:
aws_req = AWSRequest(method="POST", url=url, data=payload,
headers={"Content-Type": "application/json"})
SigV4Auth(creds.get_frozen_credentials(), "lambda", region).add_auth(aws_req)
The live run
I deployed to a real account in eu-central-1, ran the signing client, and fired an unsigned request as a negative control. This is the actual output, with the account id redacted:
Real output from deploy.sh (account id redacted):
✅ McpStrategiesRemoteStack
✨ Deployment time: 36.56s
>>> DEMO: SigV4-signed in-account client
initialize -> {"name": "mcp-strategies-demo-remote", "version": "1.0.0"}
tools/list -> ['demo_echo', 'demo_account_context']
tools/call demo_account_context -> {"account": "123456789012", "region": "eu-central-1", "function": "mcp-strategies-demo-remote"}
OK: a SigV4-signed, in-account client reached the remote MCP server, listed its tools, and invoked one.
>>> DEMO: negative control — unsigned curl should be 403
unsigned GET status: 403
That 403 is the whole point. A request without a valid in-account signature does not reach the tool. The signed client, using credentials from a principal in the same account, gets through and invokes the tool, which reports back the account and region it ran in. That’s evidence the front door demands IAM auth and rejects the unsigned internet, the full account-boundary claim would also need a negative control with a signed but cross-account request, which this demo doesn’t include.
Tearing it down, for real
A blog post that deploys real infrastructure owes you a teardown, and a careful one. The deploy driver registers a teardown trap before it deploys anything, so the stack is destroyed even if the run aborts or is interrupted. The trap covers normal exit, Ctrl-C, and SIGTERM; a SIGKILL or a host crash would still skip it, which is exactly why the teardown verifies rather than assumes:
From deploy.sh, teardown that runs on exit, interrupt, or termination:
cleanup() { echo ">>> TRAP: running teardown"; bash "$HERE/teardown.sh"; }
trap cleanup EXIT SIGTERM SIGINT
The teardown does not just call destroy and trust it. It verifies the two things the deploy created that serve traffic or hold state, the function and the stack. Artifacts outside the stack, like the CDK bootstrap stack or a Lambda-created log group, are deliberately out of scope of this check:
Real teardown output:
✅ McpStrategiesRemoteStack: destroyed
[teardown] verified: mcp-strategies-demo-remote is gone
[teardown] stack status: DELETE_COMPLETE
[teardown] clean.
The resources existed for about a minute. This matters beyond tidiness: the first time I ran the driver it failed on a shell-quoting bug and deployed nothing, and the trap still ran the teardown and confirmed a clean account. A teardown you trust is one that checks, not one that assumes.
Rung three: the managed gateway
The top rung is a managed MCP gateway. On AWS that is Amazon Bedrock AgentCore Gateway, a managed service that is a unified entry point for agents to reach tools over MCP, handling authentication and invoking callable targets like Lambda functions and REST APIs [2]. It adds something my hand-rolled remote server does not: semantic tool search, which ties straight back to the token-tax problem from the tool-design post. The gateway can present a scoped-down toolset instead of shipping every definition on every call. One detail worth knowing before you build: semantic search must be enabled when you create the gateway and cannot be added later [2].
I did not deploy a gateway here, because the account-bounded Lambda is enough to show the access model concretely. The gateway is the graduation path when you have many servers and want one endpoint, central auth, and context optimization.
Extending to internet-facing, deliberately
The deploy above is account-bounded on purpose. Making an MCP server reachable from the internet is a separate, deliberate decision, and the governance pillar tells you what it requires. The path, which I describe in the code comments but do not deploy:
- Swap the front door. A Function URL only supports
AWS_IAMorNONEas auth types [5], so token-based auth means putting a JWT-capable service in front: an API Gateway HTTP API with a JWT or OIDC authorizer (Amazon Cognito as the issuer, or Amazon Bedrock AgentCore Identity issuing scoped tokens) [3][6]. - Validate the token audience. Per the MCP specification, the server must verify that a token was issued for this server, so tokens minted for a different server are rejected [4]. For a JWT access token that means checking the
audclaim; for an opaque token, introspection or an equivalent issuer-side validation does the same job. - Add a public-edge defense. AWS WAF with rate-based and IP or geo rules, attached at a WAF-supported front door such as a CloudFront distribution or an API Gateway REST API stage; WAF does not attach to a bare Function URL.
I kept those undeployed on purpose. The cheapest way to avoid an exposed agent endpoint is to not open one until you have the authorizer, the audience check, and the edge protection in place: in the same deploy, not as a follow-up.
Running it yourself
The companion code for this post is on GitHub at stechr/schristoph-blog-samples › 2026-06-12-mcp-strategies/hosting:
export MCP_ACCOUNT_ID=<your-account-id>
export MCP_REGION=eu-central-1
export MCP_PROFILE=<your-aws-profile>
bash deploy.sh
The driver bootstraps, deploys, runs the signed client and the 403 negative control, then tears down and verifies. No account ids are baked into the code: everything comes from the environment.
Up next: Part 4, Governance in Code
This post bounded who can reach the server. Part 4 goes inside it: the token-isolation model where a scoped token makes a hallucinated delete fail safely, and per-tool rate limiting with the standard headers. It is the security story the deploy here only set the stage for.
Sources
- Model Context Protocol strategies on AWS (PDF), the hosting pillar: local, remote, and gateway.
- Create an Amazon Bedrock AgentCore Gateway, managed MCP entry point; semantic search is set at creation.
- Provide identity and credential management with Amazon Bedrock AgentCore Identity.
- MCP specification, Authorization Security Considerations, audience validation and the ban on token passthrough.
- AWS Lambda, Function URL access control with IAM, auth types, required permissions, and resource-based policies.
- Amazon API Gateway, Control access to HTTP APIs with JWT authorizers.
About the Author
Stefan Christoph is a Principal Solutions Architect at AWS, focused on agentic AI, media & entertainment, and helping builders move from demo to production. He writes about AI architecture, developer productivity, and the future of software.
This is a personal blog. Opinions expressed here are my own and do not represent the views or positions of my employer.
Cross-posted to LinkedIn
🎬 Also available as a blog walkthrough video on YouTube
❤️ Created with the support of AI (Kiro)
📝 Last updated: August 17, 2026 — Technical corrections from a quality audit; Editorial polish for readability and voice