Skip to content
Open the app

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:

text
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:

text
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 mustWhy
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 resumeA redeploy replaces the running copy. A stateless server answers GET and DELETE with 405
Listen on the port in PORT (default 8000) on every interfaceThe address reaches the server over the cluster's network, never over loopback
Answer 200 on the manifest's health path once it can take callsA new copy takes traffic only when it is ready; without health, an open port counts as ready
Stop when it receives SIGTERMA redeploy stops the old copy that way
Read the caller from the identity headersSee What your server receives
Keep nothing it needs on diskOnly 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:

json
{
  "$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"]
}
FieldRequiredWhat it saysDefault
$schemanoThe schema's address, for your editor—
namenoA name for people, 1 to 64 characters—
runtimeyesnode22, the one runtime—
rootnoThe directory inside the repository to build and run fromthe project's app dir, else the repository's root
installnoThe install command, one linenone
buildnoThe build command, one linenone
startyesThe command that starts the server, one line—
portnoThe port the server listens on, 1024 to 655358000
pathnoThe path MCP answers on, starting with //mcp
healthnoA path that answers 200 once the server is readynone
repositoriesnoUp to 20 repositories the server works on, each owner/name with the permissions it needs: contents, pull_requests, issues, each read or writenone
envnoUp to 64 names of environment variables the server reads; an Admin sets their valuesnone

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:

RuleRefused as
The file is a JSON object of at most 64 KiB, named kadmo-mcp.json, at the repository's rootmanifest_invalid
root is a relative directory inside the repository, without . or .. segmentsmanifest_invalid
install, build and start are one line each, at most 500 charactersmanifest_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 twicemanifest_invalid
Each repository asks for at least one permissionmanifest_invalid
Your account's GitHub App installation covers every repository listedcredential_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:

  1. Attach: give the server a slug and choose who may use it. Kadmo reads kadmo-mcp.json at the head of the project's default branch (or the branch you name) and checks it and the repositories it lists. Nothing runs yet.
  2. 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.
  3. Deploy now: Kadmo builds the branch's head and starts the server. Its address answers once the first deployment is live.
SettingRule
Slug1 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 itmembers (every member of the account) or admins (the account's Admins only)
BranchThe project's default branch unless you name another
Environment valuesAt 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:

HeaderValue
Kadmo-User-IdThe member's id, a number
Kadmo-User-EmailThe member's email address
Kadmo-User-NameThe member's display name; may be empty
Kadmo-Account-IdThe account's id, a number
Kadmo-Account-SlugThe account's slug
Kadmo-Roleadmin or member
Kadmo-ClientThe OAuth client's name, or personal_token, or login_token (the API token that kadmo login stores)
Kadmo-ServerYour server's slug
Kadmo-AssertionThe 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:

json
{
  "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:

  1. it is HS256 and its signature verifies with KADMO_GATEWAY_SECRET (the secret's UTF-8 bytes);
  2. aud is your address, KADMO_PUBLIC_URL;
  3. iss is the origin of KADMO_API_URL;
  4. it has not expired: an assertion holds for 120 seconds;
  5. it has a jti;
  6. sub, account_id and role match Kadmo-User-Id, Kadmo-Account-Id and Kadmo-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 ​

NameWhat
PORTThe port to listen on: the manifest's port
KADMO_SERVERYour server's slug
KADMO_ACCOUNTYour account's slug
KADMO_PUBLIC_URLYour server's address, the assertion's aud
KADMO_API_URLKadmo's app, https://app.kadmo.ai: the doors' host, and its origin is the assertion's iss
KADMO_SERVER_TOKENYour server's token for the doors (below)
KADMO_GATEWAY_SECRETThe assertion's secret
KADMO_COMMITThe commit this copy runs
your env namesThe 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.

DoorWho 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 ​

bash
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:

json
{
  "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 codeWhy
400 repo_invalidrepo is not owner/name
401 unauthorizedNo server token, or not one Kadmo knows: the server was removed
404 server_not_foundThe path names another server than the token's
403 server_stoppedThe server is stopped
403 repository_not_grantedThe 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_unavailableGitHub's rate limit, or the App is unavailable; retry later, after Retry-After when it is sent
502 github_unavailableGitHub 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 callsWith the rule membersWith the rule admins
An Admin of the accountadmittedadmitted
A member of the accountadmitted403 admin_required
Anyone of another account403 not_a_member403 not_a_member
No credential, or an expired one401: the client starts the sign-in401

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:

AnswerWhen
404 not_foundNo server at this address, the server is stopped, or it has never gone live
503 server_starting, Retry-After: 10The first deployment is still building or starting
503 upstream_unavailable, Retry-After: 10The server is live but cannot be reached right now
402 trial_expiredThe 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 ​

TriggerWhen it deploys
A push to the server's branch on Kadmo GitWithin a minute
A push to the server's branch on GitHubAt once when the account's GitHub App receives push events, else at the next check
The check every five minutesWhen the branch's head has moved since the last deployment
Deploy nowThe branch's head, at once; refused with env_missing while a name of the manifest's env has no value
Roll backAn 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.

ActionWhat it does
StopThe server stops answering (404) and pushes no longer deploy it. Deploy now brings it back
RemoveThe server, its environment and its deployments are deleted, and its token stops working. The address stops answering
Archiving the project or its workspaceStops the server
Deleting the projectRemoves 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]:

text
[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 41s

A build that fails ends with [kadmo] FAILED <step>: <reason>:

StepTypical reason
fetchThe source could not be fetched, or is larger than 50 MiB
configA deployment setting is malformed, for example a root with a character outside A–Z, a–z, 0–9, ., _, - and /
unpackThe archive has no directory root, or it is empty
install, buildThe 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 ​

WhatLimit
Servers per account3 (server_limit beyond)
Copies of a server1, and 2 for the moment a redeploy hands over
CPU0.25 of a core reserved, 0.5 at most
Memory512 MiB
Disk1 GiB, the app directory and /tmp together
The repository's archive50 MiB, fetched within 120 seconds
install and build300 seconds together
A request's body16 MiB
A response streamNo limit on its length; closed after 24 hours with nothing sent either way
The manifest64 KiB; 20 repositories; 64 env names
Environment values32 KiB each, 512 KiB in all
The slug1 to 40 characters
The build logthe 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 ​

  1. Open Settings → Connectors and choose Add custom connector.
  2. Name it content and paste the address:
text
https://mcp.kadmo.ai/acme/content
  1. Choose Connect and sign in.

Claude Code ​

bash
claude mcp add --transport http content https://mcp.kadmo.ai/acme/content

Then run /mcp in Claude Code and choose Authenticate to sign in.

Cursor ​

In ~/.cursor/mcp.json:

json
{
  "mcpServers": {
    "content": {
      "url": "https://mcp.kadmo.ai/acme/content"
    }
  }
}

VS Code ​

In .vscode/mcp.json:

json
{
  "servers": {
    "content": {
      "type": "http",
      "url": "https://mcp.kadmo.ai/acme/content"
    }
  }
}

Codex ​

In ~/.codex/config.toml:

toml
[mcp_servers.content]
url = "https://mcp.kadmo.ai/acme/content"

Then sign in:

bash
codex mcp login content

A headless client with a personal token ​

json
{
  "mcpServers": {
    "content": {
      "type": "http",
      "url": "https://mcp.kadmo.ai/acme/content",
      "headers": { "Authorization": "Bearer kadmo_pat_your-token" }
    }
  }
}

Troubleshooting ​

What you seeCauseFix
manifest_invalid at attach or deploykadmo-mcp.json is missing at the branch's head, not JSON, or breaks a rule; the message names the fieldFix the file (see The manifest) and push
credential_missingThe account's GitHub App installation does not cover a repository the manifest lists, or GitHub refused the account's tokenAdd the repository to the installation, or fix the account's GitHub connection under Integrations
branch_not_foundThe branch does not exist, or the account cannot read itName an existing branch
provider_unsupportedThe project's repository is on a host Kadmo cannot deploy a server fromAttach a project on GitHub or on Kadmo Git
server_existsThe project already has a serverOpen the project's server instead; one project holds one server
env_limit, env_value_invalidThe environment holds too many names, a value is larger than 32 KiB, or the values pass 512 KiB in allRemove a name you no longer read, or keep a large value outside the environment
env_missing on Deploy nowA name of the manifest's env has no valueSet it on the server's page
env_name_reservedPORT or a KADMO_ name was given a valueUse another name: Kadmo sets these
slug_takenAnother server of the account has the slugChoose another slug
slug_locked_while_liveThe slug of a running server cannot changeStop the server, change the slug, deploy again; clients connect again at the new address
server_limitThe account runs as many servers as it mayRemove a server you no longer use
A deployment failedThe build or the start failedRead the build log's last line, then the log tail
503 server_startingThe first deployment is still runningWait; the client retries after Retry-After
404 not_foundWrong address, or the server is stopped or never went liveCheck the address on the server's page; deploy it
The client asks to sign in again and the sign-in cannot find the serverThe address is wrong: Kadmo asks for a sign-in before it says whether a server answers thereCopy the address from the server's page
403 not_a_memberYou are signed in to another accountSign in as a member of the server's account
403 admin_requiredThe server admits Admins onlyAsk an Admin, or have the access rule changed
400 reserved_headerThe client sent a Kadmo- headerRemove it: Kadmo sets these
403 repository_not_granted from the GitHub-token doorThe manifest of what runs does not list the repository, or the installation does not cover itList it in kadmo-mcp.json and deploy, or add it to the installation