# 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. ## Build with your AI app Install the `closedmcp-builder` skill first: it tells your AI app how to build, test and publish a Serverless MCP. Download [closedmcp-builder.zip](https://closedmcp.com/skills/closedmcp-builder.zip) (or the single file at `https://closedmcp.com/skills/closedmcp-builder/SKILL.md`) and unzip it into: - Claude Code: `~/.claude/skills//` (or `.claude/skills/` in a repo) - Codex CLI: `${CODEX_HOME:-~/.codex}/skills//` or `~/.agents/skills//` (or `.agents/skills/` in a repo) - claude.ai: Settings > Capabilities > Skills, upload `.zip` [closedmcp-basics.zip](https://closedmcp.com/skills/closedmcp-basics.zip) installs the same way and helps anyone whose AI app uses ClosedMCP. Then describe the MCP you want and say "Deploy this MCP". ## Start here Choose the destination **before creating the project**. Ask only if the user has not said who should use it. For a named group, call `closedmcp_group_list` first, or `closedmcp_group_create` if it does not exist. Keep its returned `id`. Pass that ID as `group` on **every** project call: create, files, settings, test, preview, publish, versions, logs, and sharing. You can do this through your current personal/company connection; you do not have to reconnect to build for a group. Each call checks current membership and the group's own permissions and AI-app approval. A connection already scoped to one group cannot target another group. For example, after creating `work-related` with ID `g_…`: ```json {"name":"work-chat","template":"python","group":"g_…"} ``` Use the returned project ID and the same `group` for all following calls. Check `workspace.id` and `workspace.name` in the results. Omitting `group` uses the connected workspace; creating a group does not switch it. Never publish personally as a substitute for a group the user requested. When publishing, use `share_with: "group:work-related"` with that group ID. `share_with` selects the audience **inside the selected workspace**; it does not move a personal project. Group members connect using the returned `workspace.mcp_url` to discover and call the published tools. Building for a group does not add that group's native tools to a personal connection. If approval is required, show the returned group-specific review link and stop until its owner/admin approves. Do not ask for a key in chat, retry into personal storage, or say reconnecting alone fixes a permission failure. 1. `closedmcp_create_project` with a lowercase name and `template` `python` (the default) or `typescript`. The project starts as a working app with one example tool, `hello`, already served the way ClosedMCP calls it. 2. Add your tools to `server.py` or `server.ts` with `closedmcp_write_files`. Keep the `serve(...)` line at the end. 3. `closedmcp_test`: it starts the app in the sandbox and checks it the way ClosedMCP checks a new version, in about 10 to 30 seconds. Pass `call` to try one tool. Green means the version will go live. 4. `closedmcp_preview` to try it privately, then `closedmcp_publish`. ## What your app must do - Serve MCP over Streamable HTTP at `/mcp` on `0.0.0.0:8080`, stateless. Not stdio: `mcp.run()` with no arguments and `StdioServerTransport` read stdin, which a hosted app does not have, so the app exits and never goes live. Preview and publish refuse source that only talks stdio (pass `force: true` if the check is wrong). With FastMCP without the starter, pass `host="0.0.0.0", port=8080` and run it with `transport="streamable-http"`; its defaults are stdio and `127.0.0.1:8000`. - Answer `initialize` and `tools/list` within 180 seconds of starting, or the new version does not go live. `PORT=8080` and `HOST=0.0.0.0` are set for you. - 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](/docs/library). ## A minimal app (Python) The `python` template writes an app like this one. It ends with `serve(mcp)` from `closedmcp.hosted` (SDK 0.3.0 or later), which does what the `uvicorn.run` line below does. `server.py`: ```python 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) The `typescript` template writes `server.ts`, which Node 22 runs as is with `node server.ts` (it strips the types). It ends with `serve(build)` from `@closedmcp/sdk/hosted` (SDK 0.3.0 or later), which does what the HTTP server below does. In JavaScript, `server.js` (CommonJS, so the packages in the base image are found): ```js 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. `template`: `python` (default), `typescript` or `none` | | 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` | | | Check | `closedmcp_test` | installs dependencies, starts the app, then MCP `initialize`, `tools/list` and an optional `call` over HTTP, as the platform does before a version goes live. Returns `passed`, the tools and the app's log. `entry_command` and `dependency_lockfile` default to the starter's | | Test | `closedmcp_run` | a shell command on the latest commit, in the same image as production. The sandbox lasts five minutes and the command is stopped after 285 seconds (exit code 124). Install dependencies and start or test your server in the same command. A long run streams progress every 15 s, so apps that give up on a silent request after about a minute, like Claude Code, keep waiting. | | Try privately | `closedmcp_preview` | deploys this commit so only you can call it | | Publish | `closedmcp_publish` | `entry_command`, `dependency_lockfile`, `share_with`, `needed_database`; `force` skips the stdio check | | Logs | `closedmcp_hosted_logs` | build and runtime logs of a version; the newest one by default, including one that failed | `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:` or `group:`: 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. ## Move an existing MCP to a group Use `closedmcp_move_project`, or **Serverless MCPs → project → Move to a group** in the console. Do not rebuild a second copy to change its workspace. 1. Find the destination ID with `closedmcp_group_list`. From your personal/company connection, call `closedmcp_move_project` with `project`, `destination_group` and `share_with: "me"` or `"group"`. For a project already in a group, also pass its source `group` ID. This returns a preview. 2. Review the named source, destination and access change. Source workspace users lose access; the chosen destination audience gains it. Destination admins can manage the app. Repeat with the returned project ID, the same arguments and the unexpired `confirmation` to apply. Changing the project or its settings requires a fresh preview. 3. Verify the returned destination. Use that group's MCP connection for native tools, and its `group` ID on subsequent management calls. Moving does not switch your current connection. The project owner must belong to both workspaces and be allowed to deploy in the destination. Company export also requires company admin. An AI app must be approved in both workspaces. A group-bound connection cannot move between groups: use the personal/company connection or the signed-in console. The move preserves project/app IDs, Git history, app settings and keys, development/live database data and retained versions. It changes workspace ownership and sharing without copying personal OAuth connections, MCP keys, sessions or old grants. Calls use destination workspace and user IDs. Existing records remain, but an app that keys its own records or permissions by the old IDs may need an app-specific migration; the move does not rewrite application tables. Retained versions use the new audience after rollback. Name conflicts refuse without overwriting anything. Active deployments, container services, externally shared databases and workspace-specific connector credentials must be resolved first; the tool reports the dependency. `closedmcp_transfer_project` changes owner within one workspace instead. Two groups may expose the same MCP and tool names. Each connection is scoped to its own group: equal names do not merge projects, credentials or databases. Use the connection labelled for the intended group. If both MCPs connect to the same external service/account, that service's data may still be shared. People call your tools as `__`; dashes in the app name become underscores. ## Dependencies - **Python:** `requirements.txt` or `requirements.lock` with exact `name==version` pins. `--hash=sha256:` is optional, but if one line has a hash, every line needs one. Only prebuilt wheels install. - **Node:** `package-lock.json` version 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`: `psql` works with no arguments, `DATABASE_URL` points at dev, and `LIVE_DATABASE_URL` is 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. A version that fails says why and what to do next in the `failure` of `closedmcp_project_versions` and `closedmcp_hosted_list`, for example `startup: app exited (code 0)` for a stdio server; `failureMessage` is the full sentence. An app that exits or listens on the wrong port fails within seconds, not minutes. 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 unless `also_delete_data` is true. - `closedmcp_restore_project` brings it back within seven days; publish again to go live. - `closedmcp_transfer_project` hands 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 in total; the command itself 285 seconds | | 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](/docs/event-channels). For checks on a timer, use [monitors](/docs/monitors).