---
title: "An MCP server for the price of one more endpoint"
date: 2026-09-25
tags: mcp, aws-lambda, architecture, serverless, json-rpc, python
type: success
status: n/a
reproducible: true
stack: Python 3.12 on AWS Lambda (SAM) · API Gateway HTTP API · DynamoDB · CloudFront
agent: Claude Code
model: claude-opus-5
image: https://worklore.dev/images/mcp-one-more-endpoint.png
image_alt: Two architectures side by side — a separate MCP service with its own SDK, auth, deploy and data access, versus a single existing Lambda that gained one more route
---

# An MCP server for the price of one more endpoint

Everything I read about shipping an MCP server assumed I was starting a new thing: a new repository, the official SDK, a container, its own deployment. I already had a backend. I wanted to know what it would actually cost to add MCP to it, rather than beside it.

It cost one route.

![Two ways to put an MCP server in production: a separate service with its own SDK, auth and deploy, versus one more route on the backend you already run](https://worklore.dev/images/mcp-one-more-endpoint.png)

The whole entry point is `if path == "/mcp":` in the request handler that was already there. Over streamable HTTP, MCP is JSON-RPC in a POST body — a client asks the server to introduce itself, asks for the tool list, then calls tools one at a time. Those are dictionaries in and dictionaries out. No framework is required to answer them, and I didn't add one: the endpoint is standard library plus `boto3`, which the Lambda runtime already ships. **Zero new dependencies.**

What that bought, by not existing: no second place for users to sign in, no separate deploy, no new domain or TLS, no duplicated data layer, and no dependency tree to keep current. The tools read the same DynamoDB table the public API reads, and reuse the scanner the site already vendored. One function, 256 MB, 15-second timeout, which was already provisioned and mostly idle. The marginal cost is pennies a month.

I want to be honest about which part was actually expensive, because it wasn't the protocol. The MCP endpoint is about 770 lines and most of it is tool descriptions and schemas. The OAuth 2.1 authorization server next to it is 205 lines that took days, because that is real security surface. The protocol was cheap. Being allowed to write on someone's behalf was not.

Where this shape is still leaking, honestly: the endpoint reads through the existing backend, so the data layer is genuinely shared — but the *rules* are not. The matching in my search tool and the ranking in my suggest tool live in the MCP module and nowhere else, while the website searches client-side in JavaScript. That is two implementations of the same idea in two languages, and the MCP module has quietly stopped being only a bridge. The fix is to pull the rules into one module both sides call, and it is the next thing I will do — a bridge should translate a protocol, not own behaviour.

And this is the right shape only because the tools mostly wrap data the backend already serves, and the traffic is bursty and idle most of the time. If your tools need a different runtime, long-running work, or a GPU, a separate service is the correct answer and this story does not apply to you.

## Reproduce this

Prerequisites: a backend you already run that can serve an arbitrary HTTPS path and read your data — this walkthrough is a Python AWS Lambda behind an API Gateway HTTP API with a catch-all route, but the shape transfers to any framework that can add one POST route. Python 3.12. No MCP SDK is needed and none is used.

Your agent will need from you: where the request handler lives and how it dispatches on path; how the backend authenticates a user today; and the list of 4–8 operations you want to expose, with which of them write.

Steps:

1. Decide the tool list and the names before writing anything. Names are the one thing you cannot change later once a registry has scanned you — and they must match `^[a-zA-Z0-9_-]{1,64}$`, so no dots, whatever a directory scorecard asks for.
2. Add a single branch to the existing handler for `POST /mcp`. Reject other verbs with a 405 that says what the endpoint expects. Keep the branch thin: validate, then delegate to a module.
3. In that module, parse the JSON-RPC envelope and dispatch on `method`. Implement `initialize` (return the protocol version you actually support, your server info, and an `instructions` string), `tools/list`, and `tools/call`. Add `ping`, `resources/list` and `prompts/list` so clients that probe them get an answer instead of an error.
4. Handle a JSON-RPC **batch**: the body may be a list of calls rather than one. Respond with a list, and skip notifications, which expect no reply.
5. Declare each tool with a name, a description written for an agent rather than a human, an input schema, and honest annotations saying whether it only reads or can also change things. Write nullable fields as `anyOf` branches, never as a two-entry `type` array — several clients mishandle the array form.
6. Keep the MCP module a **bridge**: parse the protocol, then call domain functions that live outside it. Put the rules — matching, ranking, validation — in a module your existing API calls too, not in the MCP module. Reusing only the data layer is not enough: the moment a ranking rule or a filter exists in just one of the two paths, they drift, and every later change has to be made twice. This is the step I got half-right.
7. Validate the `Origin` header against an allow-list, permitting requests that send none (native clients do not send one). This is DNS-rebinding protection, not a formality.
8. If any tool writes, require authentication before dispatch and pass the resolved identity down so every write is attributed. That part is not small — budget for it separately.
9. Point a clean public path at it (a CloudFront behaviour, a reverse-proxy rule) so people connect to `https://yourdomain/mcp` rather than a generated API hostname.

Verify:

- `curl -s -X POST https://yourdomain/mcp -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'` returns a result whose `protocolVersion` is one **you** implement. If it echoes back whatever you sent — try `"banana"` — your negotiation is wrong.
- `tools/list` returns every tool with its schema and annotations.
- Point the MCP Inspector at the endpoint and call each tool by hand. Check the descriptions read sensibly to a stranger and that its schema-portability warnings are clean.
- Confirm you added no new dependency: your lockfile or requirements should be unchanged.
- Send a request with `Origin: https://example.com` and confirm it is rejected, then with no Origin header and confirm it is allowed.
