Local-first gateway for exposing stdio MCP servers as remote MCP endpoints.
Levitate runs near local tools, launches one or more configured stdio MCP servers, connects as an MCP client, then exposes Streamable HTTP endpoints for Claude, ChatGPT, and other remote MCP hosts.
Claude.ai / ChatGPT
-> public HTTPS remote MCP endpoint
-> Levitate
-> local stdio MCP server
-> private tool or data system
Levitate is backend-agnostic.
Any stdio MCP server can be exposed through its own HTTP endpoint, subject to gateway authentication and backend-specific policy.
Package, CLI, Docker image, and binary artifact are named levitate.
Many useful MCP servers expose only local stdio transport. They work with Claude Desktop, Claude Code, Cursor, and other local MCP hosts, but cloud-hosted AI apps cannot connect directly. Levitate promotes those capabilities into remote MCP endpoints while keeping authentication and policy at the gateway.
- Authentication: bearer, OIDC, protected-resource metadata, local OAuth, CIMD/DCR, gateway identity, and credential operations
- Testing: automated validation and MCP Inspector smoke tests
config/: copyable deployment examples
Do not expose private local tools without authentication.
Levitate requires authentication for MCP endpoints. Static bearer tokens support local and simple deployments. OIDC/JWT validation supports Auth0 and other RS256 JWKS-backed issuers. Levitate can also issue gateway tokens through its local OAuth server for private ChatGPT-compatible deployments.
MCP servers can read or modify private data, and tunnel-published endpoints are public unless protected.
GET /health reports process liveness and GET /ready reports backend readiness.
Both endpoints are unauthenticated for deployment checks; MCP endpoints require bearer authentication.
Shutdown stops accepting HTTP traffic, gives existing connections one second to close, then force-closes all remaining HTTP connections before exiting.
In-flight requests may terminate after this grace period.
See Authentication before publishing Levitate through a tunnel or reverse proxy.
Install dependencies:
pnpm installSet a bearer token and start the deterministic fake backend:
export LEVITATE_TOKEN="$(openssl rand -hex 32)"
pnpm build
pnpm start -- --config config/fake-stdio.tomlThe fake profile listens on port 8790:
http://127.0.0.1:8790/mcp
Check process and backend:
curl http://127.0.0.1:8790/health
curl http://127.0.0.1:8790/readyAuthenticated MCP clients must send:
Authorization: Bearer <LEVITATE_TOKEN>
See Testing for MCP Inspector commands and expected results.
Choose the smallest example matching the deployment:
| Use case | Example | Notes |
|---|---|---|
| Static bearer token | config/bearer.example.toml |
Small local, private, or manually managed deployment |
| External OIDC/JWT | config/oidc.example.toml |
Auth0 or another RS256 JWKS-backed provider |
| Local OAuth server | config/oauth-as.example.toml |
ChatGPT with CIMD/DCR, PKCE, manual approval, and local JWT issuance |
| Gateway-wide OAuth | config/oauth-gateway.example.toml |
One OAuth identity shared by multiple named backends |
| Multiple backends | config/multi-backend.example.toml |
Independent routes, processes, instructions, and tool policies behind shared authentication |
Copy an example to an ignored local file before adding machine paths or deployment values:
cp config/bearer.example.toml config/bearer.local.tomlExample files contain no secrets. Prefer environment variables for bearer tokens and approval secrets. Use absolute state and key paths when Levitate runs under a service manager with a different working directory.
The MCP endpoint defaults to /mcp.
Set server.mcp_path to expose a single backend at another path:
[server]
name = "example"
mcp_path = "/brain/mcp"The path must start with /.
GET /health remains unchanged.
This setting alone does not enable multi-backend routing or backend aggregation.
Levitate permits every browser origin by default for backward compatibility. Restrict browser access with an exact origin allowlist:
[server.cors]
allowed_origins = ["https://chatgpt.com", "https://example.com"]Origins must use HTTP or HTTPS and cannot contain paths, queries, or fragments.
Requests without an Origin header remain available to non-browser MCP clients.
CORS does not replace bearer authentication or OAuth validation.
Authentication applies at the gateway level. Supported modes:
- static bearer token
- external OIDC/JWT validation
- Levitate-issued OAuth tokens
Named backends can use one gateway-wide OAuth audience when every authenticated connector should access every backend. Use service mode or separate Levitate deployments when backends need separate token audiences.
See Authentication for configuration, discovery endpoints, CIMD/DCR behavior, approval, client management, key rotation, and storage limits.
Levitate filters backend tools before advertising them to remote clients.
- If
tools.allowis configured, only listed tools are advertised and callable. tools.denyis always enforced as an extra guard.- Direct calls to denied tools return an MCP tool error and are logged.
This lets a private backend expose read-only or append-only tools while hiding destructive tools.
io.github.iomz.levitate/* in MCP request _meta is reserved for metadata Levitate itself authors.
Levitate strips every inbound entry under that namespace before forwarding a request to a backend, so a remote client cannot put metadata on the wire wearing Levitate's name.
Stripping is unconditional and applies to every backend, including backends that know nothing about Levitate.
All other _meta entries are forwarded rather than filtered by Levitate; a request carrying only reserved keys reaches the backend indistinguishable from one that carried none.
They are not preserved byte-for-byte: inbound _meta is parsed before the sanitizer runs, and that parse normalizes some input, dropping an own __proto__ key for example.
Matching ignores case, because no legitimate key differs from this namespace by case alone.
Levitate can assert the authenticated caller's identity to a backend that asks for it.
It is off by default, enabled per backend, and carried in standard MCP _meta, so an ordinary stdio MCP server stays unaware that Levitate exists and needs no change.
[backends.notes.principal]
enabled = trueLegacy single-backend deployments use a top-level [principal] block.
The principal travels on tools/call under io.github.iomz.levitate/principal:
{
"issuer": "https://levitate.example.com",
"subject": "local-owner",
"subject_type": "owner",
"auth_kind": "levitate",
"client_id": "https://chatgpt.com/connector/oauth/...",
"scopes": ["gateway:access"],
"asserted_at": "2026-09-21T09:30:00.000Z"
}tools/list is not augmented.
email appears only when the identity provider marked it verified, and is a display attribute; identity is keyed by issuer plus subject.
The object is built field by field from an allowlist and every field is type-checked before it is copied, so no token, authorization header, raw claim or other authentication material can reach a backend through it, and a malformed authentication result produces no principal rather than a coerced one.
subject_type states what the identity actually is:
auth.mode |
subject_type |
meaning |
|---|---|---|
oidc |
user |
a person, as identified by the external IdP |
levitate |
owner |
the deployment owner, because Levitate's own authorization server issues one configured subject for the whole deployment |
bearer |
— | refused; a shared secret identifies no one |
principal.enabled with auth.mode = "bearer" fails at configuration load rather than sending a fabricated identity.
Propagation fails closed. When it is enabled but no principal can be asserted, the tool call is refused with an MCP tool error and the backend is never invoked, because an enabled backend that silently received a principal-less call would be indistinguishable from one that never opted in.
tools/list keeps working in that state.
Only Levitate authors metadata under io.github.iomz.levitate/*; inbound values in that namespace are stripped before a request crosses into the backend process, so a value observed there came from Levitate.
That guarantee rests on the stdio process boundary: a backend's only writer is the Levitate process that spawned it, which is why no signature is involved.
A backend that relies on propagation for a privileged operation must treat an absent principal as unauthenticated for that operation, and must not fall back to trusting the local process.
A backend needing per-user authorization must require subject_type of user and refuse values it does not recognize, including ones added later.
Levitate asserts who the caller is. What that identity may do — which records it sees, which operations it may perform, how it maps onto the backend's own user model — stays the backend's decision.
The shape is unversioned and additive-only: new optional fields may appear, existing fields never change meaning or type and are never removed, and an incompatible future contract takes a new reserved key rather than mutating this one.
A backend's own instructions — the orientation text its MCP server returns from initialize — are forwarded to remote clients unchanged by default.
Instructions are part of MCP discovery, and they are the only place a server can state what it does not know, so dropping them at the gateway makes an empty result indistinguishable from a boundary.
Instructions can instead be configured inline or loaded from a file, which replaces whatever the backend advertises:
[instructions]
file = "/path/to/SKILL.md"Per backend, the advertised value resolves in this order:
instructions.text, when set and not empty.instructions.file, when set and not empty.- The backend's own instructions from its initialize result.
A file containing nothing but whitespace counts as unset, the same as an empty instructions.text.
Otherwise truncating a file would suppress the backend's own instructions and serve an empty string in their place, with nothing to say so.
Set instructions.passthrough = false to suppress a backend's text without replacing it.
Levitate logs which source applied, so a backend author can see whether their text was forwarded, overridden, or suppressed.
Levitate passes instructions through MCP server initialization using official TypeScript SDK Server instructions option.
A configured file that cannot be read fails startup, naming the backend and the path. Configuring one states that its text should be served in place of whatever the backend advertises, so serving something else instead would be the same silent substitution Levitate refuses to make in the other direction. Every configured file is read before any backend process is spawned, so a bad path costs no start and stop cycle; the backend's own value arrives from its handshake and is combined with the configured one once it has started.
Levitate can host multiple MCP backends by assigning each backend its own HTTP MCP endpoint:
/notes/mcp
/ingest/mcp
/tools/mcp
Each endpoint behaves as an independent MCP server backed by one stdio process.
[server]
name = "private-gateway"
host = "127.0.0.1"
port = 8787
[backends.notes]
mcp_path = "/notes/mcp"
[backends.notes.stdio]
command = "notes-mcp"
[backends.notes.tools]
deny = ["delete_note"]
[backends.ingest]
mcp_path = "/ingest/mcp"
[backends.ingest.stdio]
command = "ingest-mcp"Named backends cannot be combined with the legacy top-level [stdio] configuration.
Backend paths must be unique and cannot overlap health, readiness, OAuth, or well-known routes.
Policies, instructions, environment, process lifecycle, and readiness remain backend-specific.
GET /ready succeeds only when every backend is ready and includes per-backend states.
Startup failure closes every backend already started before Levitate exits.
Static bearer and external OIDC authentication apply at gateway level across every backend.
Local Levitate OAuth can also apply at gateway level when oauth.resource.mode = "gateway" uses one origin-level audience for every backend.
Service mode remains rejected with multiple named backends so a token naming one MCP path is never silently accepted by another.
Levitate does not merge backend tool namespaces into one /mcp endpoint.
MCP already provides tool discovery through tools/list, so Levitate preserves backend tool names and schemas unless explicit policy filters or blocks them.
This avoids tool-name collisions, namespace rewriting, ambiguous routing, and policy mistakes.
ChatGPT UI labels can change independently of Levitate. These steps were verified in ChatGPT developer mode on 2026-08-09; also check OpenAI's current connection guide.
Before installing an endpoint, enable ChatGPT developer mode under Settings > Security and login and confirm Levitate is reachable through public HTTPS. Some ChatGPT builds also expose Developer mode under Settings > Plugins. Create one ChatGPT plugin entry for each named MCP endpoint that should appear separately.
-
Open the plugin browser from Plugins in the ChatGPT sidebar, or use Settings > Plugins > Browse plugins.
-
Select + in the top-right corner.
-
Complete the New Plugin dialog:
Field Value Icon Optional. assets/levitate-icon-64.pngfits current 10 KB upload limit.Name Any clear per-endpoint name, such as Levitate/NotesorLevitate/Admin.Description Optional. Connection Select Server URL and enter full MCP endpoint, such as https://levitate.example.com/notes/mcp.Authentication Select OAuth. Levitate requires OAuth discovery; Streamable HTTP is not an authentication option. -
Review the custom MCP server warning, then check I understand and want to continue.
-
Select Create.
-
In Add to ChatGPT, select Sign in with .
-
On the Levitate approval page, verify the client, redirect origin, resource, scopes, and registration method.
-
Enter the approval secret stored in the environment variable named by
oauth.as.approval_secret_env, then select Approve. -
Enable the new plugin in a ChatGPT conversation and invoke one of its tools.
The approval secret is not an access token.
Levitate issues the access token only after approval and ChatGPT's authorization-code and PKCE exchange.
Clients registered for the refresh_token grant receive a rotating refresh token, allowing ChatGPT to renew expired access tokens without repeating manual approval.
Existing connections must authorize once after deployment to obtain an initial refresh token; no stored-state migration is required, and later renewals occur silently.
Each rotation renews the refresh token for oauth.as.refresh_token_ttl_seconds; inactive connections eventually expire, while active connections remain linked.
Reuse of a rotated token within the replay-detection window revokes its family.
Levitate stores only refresh-token hashes in the mode-0600 file configured by oauth.as.refresh_token_store_file.
When that path is omitted, Levitate derives it from oauth.as.client_store_file.
Do not paste the approval secret into a ChatGPT conversation.
Selecting OAuth does not choose between CIMD and DCR.
The Levitate approval page reports which registration method ChatGPT used.
If it reports Dynamic Client Registration, the connection tested DCR rather than CIMD; an existing registered DCR client remains usable after new DCR registrations are disabled.
For a CIMD-only deployment, keep oauth.as.dcr.enabled = false, enable oauth.as.cimd, and treat the CIMD registration label on the approval page as smoke-test evidence.
Run Levitate locally, then expose it through reverse proxy, Cloudflare Tunnel, ngrok, or another HTTPS tunnel:
cloudflared tunnel --url http://127.0.0.1:8787or:
ngrok http 8787Configure the remote MCP host with the public HTTPS endpoint and selected authentication mode.
Build:
docker build -t levitate .Run:
docker run --rm -p 8787:8787 \
-e LEVITATE_TOKEN="$LEVITATE_TOKEN" \
-v "$PWD/config:/app/config:ro" \
levitateFor local stdio servers that need host files, mount the required vault or tool paths and adjust config paths for the container.
Levitate uses the official @modelcontextprotocol/sdk v1 Streamable HTTP implementation:
- backend:
StdioClientTransport - remote endpoint:
WebStandardStreamableHTTPServerTransport - HTTP framework: Hono, following the SDK Hono example
The remote endpoint defaults to /mcp and uses JSON responses from Streamable HTTP for straightforward request/response behavior.
Deployments can change the endpoint path with server.mcp_path or define independent paths for named backends.
During MCP initialization, Levitate advertises its package version, human-readable title, description, project website, and 64x64 PNG icon through serverInfo.
The icon is available without authentication at /assets/levitate-icon-64.png; clients decide whether and where to display the metadata.
Compatibility should be validated against each target remote MCP host because connector behavior can differ.
- Hosted multi-user service or multi-user management
- Automatic aggregation of backend tool namespaces
- Backend-specific wrapper behavior
- Shared multi-node OAuth state, rate limits, or zero-interruption key rotation
- Persistent audit database
See Testing for full validation and smoke-test workflow.
pnpm test
pnpm typecheck
pnpm build