Serverless MCPs
A Serverless MCP is an MCP server whose code ClosedMCP hosts for you. You, or your AI app, write the code with ClosedMCP tools, test it in a sandbox and publish it. People in your company then use its tools through the ClosedMCP connection they already have. Nobody installs anything.
The app sleeps when nobody uses it and wakes on the next call, so the first call after a quiet period can take a few seconds.
What your app must do
- Serve MCP over Streamable HTTP at
/mcpon0.0.0.0:8080, stateless. - Answer
initializeandtools/listwithin 60 seconds of starting, or the new version does not go live. - Expect no internet. The app can reach its own database and nothing else. Everything that changes the app arrives as a tool call through ClosedMCP.
- Finish each tool call within 5 minutes.
Who is calling
ClosedMCP signs each person in and tells your app who is calling with four headers:
| Header | Value |
|---|---|
X-ClosedMCP-User | the person's ClosedMCP ID (stable) |
X-ClosedMCP-Email | their email |
X-ClosedMCP-Org | their company's org ID, the slug (e.g. dacme_io) |
X-ClosedMCP-Client | the AI app they use (display only) |
ClosedMCP removes any Authorization header and any copy of these headers sent by someone else, then sets them itself. Inside a hosted app you can trust them.
Do not use the "Log in with ClosedMCP" SDK server wrapper in a hosted app; see The ClosedMCP library.
A minimal app (Python)
server.py:
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_headers
import uvicorn
mcp = FastMCP("hello")
@mcp.tool
def hello() -> str:
"""Say hello to the person calling."""
email = get_http_headers(include_all=True).get("x-closedmcp-email", "")
return f"Hello {email}"
if __name__ == "__main__":
uvicorn.run(mcp.http_app(path="/mcp", stateless_http=True), host="0.0.0.0", port=8080)requirements.txt can be empty: the base image already has FastMCP, the MCP SDK and PostgreSQL drivers. Publish with entry_command: ["python", "server.py"] and dependency_lockfile: "requirements.txt".
A minimal app (Node)
server.js (CommonJS, so the packages in the base image are found):
const http = require("node:http");
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
function build() {
const server = new McpServer({ name: "hello", version: "1.0.0" });
server.registerTool("hello", { description: "Say hello to the person calling." }, async (extra) => ({
content: [{ type: "text", text: `Hello ${extra.requestInfo?.headers["x-closedmcp-email"] ?? ""}` }],
}));
return server;
}
http.createServer(async (req, res) => {
if (req.url !== "/mcp" || req.method !== "POST") return res.writeHead(405).end();
const server = build();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res);
}).listen(8080, "0.0.0.0");A Node app needs package.json and package-lock.json (lockfile version 2 or newer) at the project root, even with no dependencies of its own. Publish with entry_command: ["node", "server.js"] and dependency_lockfile: "package-lock.json".
Build, test, publish
| Step | Tool | Notes |
|---|---|---|
| Create | closedmcp_create_project | lowercase name; it becomes the app name |
| List | closedmcp_projects, closedmcp_company_projects, closedmcp_deleted_projects | yours, the company's (admins), and deleted ones you can restore |
| Rename | closedmcp_update_project | name or description |
| Write code | closedmcp_write_files | each call is one Git commit; 10 MiB in total |
| Read code | closedmcp_read_files, closedmcp_list_files, closedmcp_history | |
| Test | closedmcp_run | a shell command on the latest commit, in the same image as production, for up to five minutes. Install dependencies and start or test your server in the same command. |
| Try privately | closedmcp_preview | deploys this commit so only you can call it |
| Publish | closedmcp_publish | entry_command, dependency_lockfile, share_with, needed_database |
| Logs | closedmcp_hosted_logs | build and runtime logs of a version |
entry_command starts your server. Its first word must be node, npm, python or python3, and the file it names must be in the project.
share_with decides who gets the tools:
me: only you and your own AI apps.team:<name>orgroup:<name>: members get the tools automatically.company: everyone or only chosen people, as the company's apps policy says.
Company admins set the widest sharing allowed and who may build and publish. Change sharing later with closedmcp_share_app. Public publishing is not available.
People call your tools as <app>__<tool>; dashes in the app name become underscores.
Dependencies
- Python:
requirements.txtorrequirements.lockwith exactname==versionpins.--hash=sha256:is optional, but if one line has a hash, every line needs one. Only prebuilt wheels install. - Node:
package-lock.jsonversion 2 or newer; every package at an exact version from registry.npmjs.org with an integrity hash. Install scripts do not run. - Your own packages win over the ones in the base image.
The base image has Node 22, Python 3.12, the MCP SDKs, FastMCP, the ClosedMCP SDKs, PostgreSQL drivers and psql.
Database
closedmcp_database_enable gives the project two PostgreSQL databases:
- dev, used by
closedmcp_run:psqlworks with no arguments,DATABASE_URLpoints at dev, andLIVE_DATABASE_URLis a read-only view of live. - live, used by the published app through
DATABASE_URL.
Publish with needed_database: true. Schema changes are not copied from dev to live and are not undone by a rollback, so make your app create or migrate its own tables on start or on first use.
Settings
closedmcp_set_project_setting writes config or a secret; secret values cannot be read. The published app (and a preview) gets every setting as an environment variable of the same name, from the next publish on: a running version keeps the values it was published with, and a rollback brings back that version's values. closedmcp_run sandboxes do not get settings. Names use A-Z, 0-9 and _ and cannot override ClosedMCP's own variables (CLOSEDMCP_*, PG*, DATABASE_URL); at most 100 settings, values up to 8 KiB.
Versions and rollback
Each publish is a new version. It goes live only after it starts and answers; calls already running finish on the old version. closedmcp_hosted_rollback switches back to a kept version without rebuilding; watch closedmcp_project_versions until it says live or failed. If a rollback fails, the current version stays live. closedmcp_unpublish takes the app down now and keeps its versions.
Delete, restore, transfer
closedmcp_delete_project(with the exact name) stops the app now and removes it after seven days. Its data stays unlessalso_delete_datais true.closedmcp_restore_projectbrings it back within seven days; publish again to go live.closedmcp_transfer_projecthands it to another company member.- When the creator of a company project leaves the company, the project moves to an admin.
Limits
| What | Limit |
|---|---|
| Source | 10 MiB; no symlinks, no .env files, no private keys |
| Build | 5 minutes, 1 CPU, 1 GiB memory |
| Image | 500 MiB |
| Test run | 5 minutes |
| Tool call | 5 minutes |
Telling agents when something happens
Your app can let an AI agent wait for events, such as a new chat message, with event channels. For checks on a timer, use monitors.
For AI agents: this page as markdown is at /docs/serverless.md, and the closedmcp_guide tool returns it with topic serverless.