Hosted MCP servers
Kadmo can run an MCP server of your own. Put the server and a manifest, kadmo-mcp.json, in a repository of a workspace project, attach it, and Kadmo builds it, runs it and redeploys it on every push. It answers at its own address, behind the same sign-in as Kadmo's MCP server:
https://mcp.kadmo.ai/<account slug>/<server slug>An Admin attaches a server and sets its environment. The members of your account connect to it from their clients; nobody outside the account can. Your server is told who is calling in every request, and never receives the member's Kadmo token. It can ask Kadmo for a GitHub token narrowed to one repository its manifest names, so it holds no GitHub credential of its own.
The Kadmo admin MCP, which writes and publishes Kadmo's own blog, is such a server, and runs exactly as this page describes.
What to put in the repository
A Node.js 22 project with the manifest at the repository's root:
your-repository/
├── kadmo-mcp.json the manifest (below)
├── package.json
├── package-lock.json npm ci installs from it
├── src/ your server's source
└── dist/ what the build writes and the start command runs (not committed)The server Kadmo can run:
| It must | Why |
|---|---|
Speak MCP's Streamable HTTP transport on the manifest's path (default /mcp) | The address forwards to that path. Nothing below the address is served |
Be stateless: answer every POST on its own, with no session to resume | A redeploy replaces the running copy. A stateless server answers GET and DELETE with 405 |
Listen on the port in PORT (default 8000) on every interface | The address reaches the server over the cluster's network, never over loopback |
Answer 200 on the manifest's health path once it can take calls | A new copy takes traffic only when it is ready; without health, an open port counts as ready |
Stop when it receives SIGTERM | A redeploy stops the old copy that way |
| Read the caller from the identity headers | See What your server receives |
| Keep nothing it needs on disk | Only the app directory (your built code) and /tmp are writable, and nothing written there at run time reaches another copy |
The MCP TypeScript SDK has a stateless Streamable HTTP mode that does all of this. The runtime is Node.js 22, with npm.
The manifest
kadmo-mcp.json says how to build and start the server and what it needs. An example, with the schema line most editors use for completion and checking:
{
"$schema": "https://docs.kadmo.ai/schemas/kadmo-mcp.json",
"name": "content",
"runtime": "node22",
"install": "npm ci",
"build": "npm run build",
"start": "node dist/server.js",
"port": 8000,
"path": "/mcp",
"health": "/healthz",
"repositories": [
{ "repo": "acme/website", "permissions": { "contents": "write" } }
],
"env": ["CMS_API_KEY"]
}| Field | Required | What it says | Default |
|---|---|---|---|
$schema | no | The schema's address, for your editor | — |
name | no | A name for people, 1 to 64 characters | — |
runtime | yes | node22, the one runtime | — |
root | no | The directory inside the repository to build and run from | the project's app dir, else the repository's root |
install | no | The install command, one line | none |
build | no | The build command, one line | none |
start | yes | The command that starts the server, one line | — |
port | no | The port the server listens on, 1024 to 65535 | 8000 |
path | no | The path MCP answers on, starting with / | /mcp |
health | no | A path that answers 200 once the server is ready | none |
repositories | no | Up to 20 repositories the server works on, each owner/name with the permissions it needs: contents, pull_requests, issues, each read or write | none |
env | no | Up to 64 names of environment variables the server reads; an Admin sets their values | none |
Commands run from root, with the shell. Every copy fetches, installs and builds before start runs, and a copy that restarts does so again, so keep install and build repeatable.
The JSON Schema is published at https://docs.kadmo.ai/schemas/kadmo-mcp.json. It catches an unknown key, a missing field and a wrong type in your editor. Kadmo checks the manifest again when you attach the server and on every deploy, with rules the schema cannot carry:
| Rule | Refused as |
|---|---|
The file is a JSON object of at most 64 KiB, named kadmo-mcp.json, at the repository's root | manifest_invalid |
root is a relative directory inside the repository, without . or .. segments | manifest_invalid |
install, build and start are one line each, at most 500 characters | manifest_invalid |
An env name is capitals, digits and _, starts with a capital, and is never PORT or a KADMO_ name (Kadmo sets those) | manifest_invalid |
No env name and no repository is listed twice | manifest_invalid |
| Each repository asks for at least one permission | manifest_invalid |
| Your account's GitHub App installation covers every repository listed | credential_missing, naming the repositories |
A refusal names the field and the fault, for example kadmo-mcp.json is invalid: start: required — the command that starts the server or env.0: PORT and KADMO_ names are set by Kadmo itself.
Attaching the server
A server belongs to one project of a workspace: a GitHub repository the account can read, or the workspace's Kadmo Git repository. On the workspace's Code & files tab, an Admin opens the project's row and chooses MCP server:
- Attach: give the server a slug and choose who may use it. Kadmo reads
kadmo-mcp.jsonat the head of the project's default branch (or the branch you name) and checks it and the repositories it lists. Nothing runs yet. - Environment: set a value for every name in the manifest's
env. Values are write-only: the page lists the names, who set each and when, never a value. - Deploy now: Kadmo builds the branch's head and starts the server. Its address answers once the first deployment is live.
| Setting | Rule |
|---|---|
| Slug | 1 to 40 characters of a–z, 0–9 and -, starting with a letter or digit; one per server in the account. It is part of the address and of every client's sign-in, so it is locked while the server is live or a deployment is under way: Stop the server to change it |
| Who may use it | members (every member of the account) or admins (the account's Admins only) |
| Branch | The project's default branch unless you name another |
| Environment values | At most 32 KiB each and 512 KiB in all |
A member sees the row and the server's page read-only. A repository on GitHub needs the account's GitHub App installation to cover it, or the account's own token to read it (see Code hosting); every repository the manifest lists needs the installation.
What your server receives
Every request that reaches your server has passed Kadmo's sign-in. Kadmo removes the client's Authorization and Cookie headers and sets these:
| Header | Value |
|---|---|
Kadmo-User-Id | The member's id, a number |
Kadmo-User-Email | The member's email address |
Kadmo-User-Name | The member's display name; may be empty |
Kadmo-Account-Id | The account's id, a number |
Kadmo-Account-Slug | The account's slug |
Kadmo-Role | admin or member |
Kadmo-Client | The OAuth client's name, or personal_token, or login_token (the API token that kadmo login stores) |
Kadmo-Server | Your server's slug |
Kadmo-Assertion | The same facts, signed (below) |
A name, an email address or a client's name outside printable ASCII arrives encoded as UTF-8'' followed by the percent-encoded UTF-8 bytes (RFC 8187). A client cannot set any Kadmo- header: a request that carries one is refused before it reaches you.
Use Kadmo-Role to gate your tools: leave the admin tools out of a member's tools/list, and answer a member's direct call to one with a refusal of your own. Refuse with 401 a request that has no Kadmo-User-Id, Kadmo-Account-Id or Kadmo-Role.
The assertion
Kadmo-Assertion is a JWT signed with HS256 over your server's own secret, KADMO_GATEWAY_SECRET. Its claims, for a member Ada of the account acme calling the server content from Claude Code:
{
"iss": "https://app.kadmo.ai",
"aud": "https://mcp.kadmo.ai/acme/content",
"sub": "1042",
"email": "ada@acme.example",
"name": "Ada Lovelace",
"account_id": "17",
"account_slug": "acme",
"role": "member",
"client": "Claude Code",
"server": "content",
"iat": 1793000000,
"exp": 1793000120,
"jti": "5b0c9a6e-2f4d-4e1b-9a37-0c1d2e3f4a5b"
}To trust it, check that:
- it is HS256 and its signature verifies with
KADMO_GATEWAY_SECRET(the secret's UTF-8 bytes); audis your address,KADMO_PUBLIC_URL;issis the origin ofKADMO_API_URL;- it has not expired: an assertion holds for 120 seconds;
- it has a
jti; sub,account_idandrolematchKadmo-User-Id,Kadmo-Account-IdandKadmo-Role.
The headers alone can be trusted too: nothing but Kadmo's gateway can reach your server. The assertion is for a server that wants proof rather than the network's word.
The environment
| Name | What |
|---|---|
PORT | The port to listen on: the manifest's port |
KADMO_SERVER | Your server's slug |
KADMO_ACCOUNT | Your account's slug |
KADMO_PUBLIC_URL | Your server's address, the assertion's aud |
KADMO_API_URL | Kadmo's app, https://app.kadmo.ai: the doors' host, and its origin is the assertion's iss |
KADMO_SERVER_TOKEN | Your server's token for the doors (below) |
KADMO_GATEWAY_SECRET | The assertion's secret |
KADMO_COMMIT | The commit this copy runs |
your env names | The values an Admin set |
NODE_ENV is not set, so npm ci installs your devDependencies for the build. HOME is /tmp. Never print KADMO_SERVER_TOKEN, KADMO_GATEWAY_SECRET or a value of your own.
The two doors
Your server calls Kadmo with its server token, KADMO_SERVER_TOKEN, as the bearer. The token names your server and nothing else: it opens these two doors, and no other part of Kadmo accepts it.
| Door | Who calls it |
|---|---|
The source door, GET $KADMO_API_URL/api/mcp-servers/<server>/source?commit=<sha> | Kadmo's build step, to fetch your code at the commit it deploys. Your server never needs it |
The GitHub-token door, GET $KADMO_API_URL/api/mcp-servers/<server>/github-token?repo=<owner/name> | Your server, for a token to one repository its manifest lists |
<server> is your server's slug, KADMO_SERVER.
A GitHub token for one repository
curl -s -H "Authorization: Bearer $KADMO_SERVER_TOKEN" \
"$KADMO_API_URL/api/mcp-servers/$KADMO_SERVER/github-token?repo=acme/website"The answer is an installation token of your account's GitHub App, narrowed to that repository and to the permissions the manifest gives it:
{
"token": "<an installation token>",
"expires_at": "2026-10-24T19:00:00Z",
"repo": "acme/website",
"permissions": { "contents": "write" }
}A token holds for up to an hour. The door hands out the same token until it has ten minutes left, so keep it in memory until shortly before expires_at and ask again then, or when GitHub answers 401. Never store it or log it.
The door refuses with a JSON body { "error": "<code>", "code": "<code>", "message": "…" }:
| Status and code | Why |
|---|---|
400 repo_invalid | repo is not owner/name |
401 unauthorized | No server token, or not one Kadmo knows: the server was removed |
404 server_not_found | The path names another server than the token's |
403 server_stopped | The server is stopped |
403 repository_not_granted | The repository is not yours to ask for; reason says why: not_in_manifest, no_deployment, no_installation, other_owner, not_selected (the installation covers selected repositories, and not this one) or not_accessible (the repository does not exist, or the App lacks a permission the manifest asks for) |
503 github_unavailable, github_app_unavailable | GitHub's rate limit, or the App is unavailable; retry later, after Retry-After when it is sent |
502 github_unavailable | GitHub did not answer usefully; retry later |
A repository a push adds to the manifest is granted once that deployment runs; one it drops is refused once that deployment is live. Removing the server, or uninstalling the GitHub App, ends what it can reach.
The access rule
| Who calls | With the rule members | With the rule admins |
|---|---|---|
| An Admin of the account | admitted | admitted |
| A member of the account | admitted | 403 admin_required |
| Anyone of another account | 403 not_a_member | 403 not_a_member |
| No credential, or an expired one | 401: the client starts the sign-in | 401 |
The member signs in as for Kadmo's own server (see OAuth or a personal token): an OAuth sign-in from the client, or a personal token. An OAuth sign-in is for one server: the token a client holds for https://app.kadmo.ai/mcp, or for another hosted server, is not accepted here, so each server is signed in to once. A personal token, and the API token that kadmo login stores, work for every server of your account. Revoking a client under Connected clients, or a token under Personal tokens, ends its access.
What a client sees while your server is not serving:
| Answer | When |
|---|---|
404 not_found | No server at this address, the server is stopped, or it has never gone live |
503 server_starting, Retry-After: 10 | The first deployment is still building or starting |
503 upstream_unavailable, Retry-After: 10 | The server is live but cannot be reached right now |
402 trial_expired | The account's trial has ended (see The trial) |
Kadmo checks the credential before it says anything about the address, so a client without a valid sign-in for the address gets 401 whether or not a server answers there; these answers come after the sign-in. A redeploy of a live server never shows any of them: the running copy keeps answering until the new one is ready.
Deploying on every push
| Trigger | When it deploys |
|---|---|
| A push to the server's branch on Kadmo Git | Within a minute |
| A push to the server's branch on GitHub | At once when the account's GitHub App receives push events, else at the next check |
| The check every five minutes | When the branch's head has moved since the last deployment |
| Deploy now | The branch's head, at once; refused with env_missing while a name of the manifest's env has no value |
| Roll back | An earlier deployment's commit, at once |
Each deployment is queued, then running, then live or failed; a queued deployment that a newer one overtakes is superseded. A failed deployment leaves the live copy serving and tells the account's Admins. The server's page lists every deployment with its commit, trigger, who asked, state and times.
| Action | What it does |
|---|---|
| Stop | The server stops answering (404) and pushes no longer deploy it. Deploy now brings it back |
| Remove | The server, its environment and its deployments are deleted, and its token stops working. The address stops answering |
| Archiving the project or its workspace | Stops the server |
| Deleting the project | Removes the server |
The build log
Every deployment keeps the log of its build: the last 64 KiB of what the fetch, install and build printed, opened from the deployment's row. Kadmo's own lines start with [kadmo]:
[kadmo] init commit=3f9c2e1d… root=.
[kadmo] fetch source
[kadmo] fetched 184320 bytes
[kadmo] unpacked into /app
[kadmo] install: npm ci
…
[kadmo] build: npm run build
…
[kadmo] init ok commit=3f9c2e1d… root=. in 41sA build that fails ends with [kadmo] FAILED <step>: <reason>:
| Step | Typical reason |
|---|---|
fetch | The source could not be fetched, or is larger than 50 MiB |
config | A deployment setting is malformed, for example a root with a character outside A–Z, a–z, 0–9, ., _, - and / |
unpack | The archive has no directory root, or it is empty |
install, build | The command exited with a non-zero code, timed out (install and build share 300s), or no time left of 300s when install used it all |
What the running server prints is its log tail, the last 500 lines on the server's page.
Limits
| What | Limit |
|---|---|
| Servers per account | 3 (server_limit beyond) |
| Copies of a server | 1, and 2 for the moment a redeploy hands over |
| CPU | 0.25 of a core reserved, 0.5 at most |
| Memory | 512 MiB |
| Disk | 1 GiB, the app directory and /tmp together |
| The repository's archive | 50 MiB, fetched within 120 seconds |
install and build | 300 seconds together |
| A request's body | 16 MiB |
| A response stream | No limit on its length; closed after 24 hours with nothing sent either way |
| The manifest | 64 KiB; 20 repositories; 64 env names |
| Environment values | 32 KiB each, 512 KiB in all |
| The slug | 1 to 40 characters |
| The build log | the last 64 KiB per deployment |
Connect a client
Your server's members connect it exactly as they connect Kadmo's own server, with your server's address in place of https://app.kadmo.ai/mcp, registered under your server's slug. The examples use the server content of the account acme. Sign-in, personal tokens and Revoke work as described on MCP server.
Claude.ai and Claude Desktop
- Open Settings → Connectors and choose Add custom connector.
- Name it
contentand paste the address:
https://mcp.kadmo.ai/acme/content- Choose Connect and sign in.
Claude Code
claude mcp add --transport http content https://mcp.kadmo.ai/acme/contentThen run /mcp in Claude Code and choose Authenticate to sign in.
Cursor
In ~/.cursor/mcp.json:
{
"mcpServers": {
"content": {
"url": "https://mcp.kadmo.ai/acme/content"
}
}
}VS Code
In .vscode/mcp.json:
{
"servers": {
"content": {
"type": "http",
"url": "https://mcp.kadmo.ai/acme/content"
}
}
}Codex
In ~/.codex/config.toml:
[mcp_servers.content]
url = "https://mcp.kadmo.ai/acme/content"Then sign in:
codex mcp login contentA headless client with a personal token
{
"mcpServers": {
"content": {
"type": "http",
"url": "https://mcp.kadmo.ai/acme/content",
"headers": { "Authorization": "Bearer kadmo_pat_your-token" }
}
}
}Troubleshooting
| What you see | Cause | Fix |
|---|---|---|
manifest_invalid at attach or deploy | kadmo-mcp.json is missing at the branch's head, not JSON, or breaks a rule; the message names the field | Fix the file (see The manifest) and push |
credential_missing | The account's GitHub App installation does not cover a repository the manifest lists, or GitHub refused the account's token | Add the repository to the installation, or fix the account's GitHub connection under Integrations |
branch_not_found | The branch does not exist, or the account cannot read it | Name an existing branch |
provider_unsupported | The project's repository is on a host Kadmo cannot deploy a server from | Attach a project on GitHub or on Kadmo Git |
server_exists | The project already has a server | Open the project's server instead; one project holds one server |
env_limit, env_value_invalid | The environment holds too many names, a value is larger than 32 KiB, or the values pass 512 KiB in all | Remove a name you no longer read, or keep a large value outside the environment |
env_missing on Deploy now | A name of the manifest's env has no value | Set it on the server's page |
env_name_reserved | PORT or a KADMO_ name was given a value | Use another name: Kadmo sets these |
slug_taken | Another server of the account has the slug | Choose another slug |
slug_locked_while_live | The slug of a running server cannot change | Stop the server, change the slug, deploy again; clients connect again at the new address |
server_limit | The account runs as many servers as it may | Remove a server you no longer use |
A deployment failed | The build or the start failed | Read the build log's last line, then the log tail |
503 server_starting | The first deployment is still running | Wait; the client retries after Retry-After |
404 not_found | Wrong address, or the server is stopped or never went live | Check the address on the server's page; deploy it |
| The client asks to sign in again and the sign-in cannot find the server | The address is wrong: Kadmo asks for a sign-in before it says whether a server answers there | Copy the address from the server's page |
403 not_a_member | You are signed in to another account | Sign in as a member of the server's account |
403 admin_required | The server admits Admins only | Ask an Admin, or have the access rule changed |
400 reserved_header | The client sent a Kadmo- header | Remove it: Kadmo sets these |
403 repository_not_granted from the GitHub-token door | The manifest of what runs does not list the repository, or the installation does not cover it | List it in kadmo-mcp.json and deploy, or add it to the installation |