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.

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:
- 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. - 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. - In that module, parse the JSON-RPC envelope and dispatch on
method. Implementinitialize(return the protocol version you actually support, your server info, and aninstructionsstring),tools/list, andtools/call. Addping,resources/listandprompts/listso clients that probe them get an answer instead of an error. - 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.
- 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
anyOfbranches, never as a two-entrytypearray — several clients mishandle the array form. - 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.
- Validate the
Originheader against an allow-list, permitting requests that send none (native clients do not send one). This is DNS-rebinding protection, not a formality. - 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.
- Point a clean public path at it (a CloudFront behaviour, a reverse-proxy rule) so people connect to
https://yourdomain/mcprather 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 whoseprotocolVersionis one you implement. If it echoes back whatever you sent — try"banana"— your negotiation is wrong.tools/listreturns 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.comand confirm it is rejected, then with no Origin header and confirm it is allowed.