@computec/mcp-remote
v0.1.35
Published
Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth
Readme
mcp-remote-appengine
A CompuTec fork of mcp-remote by Glen Maddern, taken by
way of punkpeye/mcp-remote. Distributed under the MIT
License; the original work is Copyright (c) 2025 Cloudflare, Inc. See LICENSE for the
full text and the retained upstream notice.
This fork is maintained independently of upstream — please do not report issues with it to the upstream projects.
Changes in this fork:
- Access Token is now automatically refreshed 5 minutes before the expiry
- I have internally changed the reference library @modelcontextprotocol/sdk to reflect issue https://github.com/geelen/mcp-remote/issues/128
--static-oauth-client-info-base64option added where json parameter is now encoded as base64
OAuth Token Refresh Enhancements
Overview
The NodeOAuthClientProvider has been enhanced with automatic token refresh capabilities to prevent authentication failures due to expired access tokens.
Key Improvements
1. Token Expiry Read From the Token Itself
- Validity comes from the JWT
expclaim — the only expiry the resource server acts on issued_at+expires_inbookkeeping remains as a fallback, for opaque (non-JWT) tokens- A 60-second skew tolerance counts in favour of validity; refresh starts 300 seconds before expiry
- A token that arrives already expired is reported as such and withheld, instead of spinning on 401
2. Automatic Token Refresh
- When
tokens()is called and the access token is expired, it automatically attempts to refresh using the refresh token - Uses the MCP SDK's
refreshAuthorizationfunction - Seamlessly handles the OAuth metadata discovery and token exchange
3. Enhanced Token Validation
- New
checkTokenValidity()method for diagnostic purposes - Improved debug logging with expiration details
- Better error handling for invalid or malformed tokens
4. Graceful Degradation
- If a token refresh fails, the proxy first re-reads the shared token store — a concurrent instance may have refreshed successfully — and only then reports that re-authentication is required. Token files are never deleted on a failed refresh, so one instance's failure cannot destroy the refresh token other sessions seed from.
- The shared token store is
~/.mcp-auth/shared/(or$MCP_REMOTE_CONFIG_DIR/shared/). It is deliberately not undermcp-remote-<version>/, which is where it used to live: a version-scoped store made every upgrade look like a first run and forced a browser login. Tokens left behind by an older release are read once and copied into the shared store, so upgrading does not cost a login.~/.mcp-auth/mcp-remote-<version>/sessions/<id>/still holds per-session state and can be deleted freely; deletingshared/is what logs you out. - Maintains backward compatibility with existing token files
5. Path‑aware OAuth discovery builds an invalid URL
- If Authorization server has realm in path the path builded in mcp is incorrect
6. No silent hangs (0.1.24)
Fixes the failure mode where the proxy stopped answering entirely, without an error, until it was restarted:
- Every request gets an answer. A rejected upstream
send(), a closed upstream transport, or an unanswered request now produces a JSON-RPC error for the client instead of a stderr line and an orphaned request id. - Proactive refresh actually runs. The refresh decision is made on the time remaining, not
on
expires_in(the total lifetime), which never crossed the buffer and left the feature dead. A background timer refreshes one buffer ahead of expiry, so an idle proxy no longer drifts past the refresh token's own lifetime and into an interactive re-login it cannot complete — and one buffer ahead is the only moment it is armed. A token already inside the buffer gets no timer, because the only one that could be armed is a zero-delay one, and an issuer that clamps the lifetime to a session about to end (Keycloak returnsexpires_in: 1) turns that into a spin. - One refresh at a time. Concurrent callers join a single in-flight refresh instead of racing with the same refresh token, which a rotating auth server rejects for all but the first.
- The login is taken over from an instance that cannot finish it. Deferring to whoever holds the auth lock is right only while that instance can still complete the login. A live process keeps answering the liveness probe whether or not anyone is still looking at the window it opened, so the wait ends after two minutes. What the next instance then opens is the window its own pre-flight held back: the authorization URL is built once, by the SDK, before any coordination has happened, and it is kept rather than dropped so that the takeover has something to show. A lock is only ever released by the instance that still owns it, so the handover does not strand the session that took it.
- Credential files are replaced atomically. The shared store is read by every session on the
machine, and
writeFiletruncates before it fills; a reader in that window got half a file. - Bounded OAuth calls. Metadata discovery and token refresh have a timeout, so an auth server that accepts the connection and never answers can no longer stall every proxied request.
- Nothing is left holding credentials. Besides the session directories below, a startup sweep removes the temporary files an atomic write leaves behind when the process writing one is killed before it can rename it into place — each of those is a full copy of the token file.
- Session directories are cleaned up. Own directory on shutdown, plus orphans left by processes that no longer exist.
Timeout flags
Both are documented with the rest of the options under Command-line options.
Command-line options
npx -y @computec/mcp-remote <server-url> [callback-port] [options]<server-url> is required. [callback-port], if given, is the local port the OAuth callback
listens on; leave it out and a port is derived from the server URL, reused from an earlier
registration, or picked from what is free. Pinning a port that disagrees with an existing client
registration deletes that registration and forces the client to register again.
| Option | Default | What it does |
|---|---|---|
| --debug | off | Writes a detailed log per session to <store>/mcp-remote-<version>/sessions/<id>/<server-hash>_debug.log. Turn this on before reporting a login problem — the order of the lines is usually the whole diagnosis. |
| --host <hostname> | localhost | Hostname used in the OAuth redirect_uri. Change it only if the browser cannot reach localhost. |
| --transport <strategy> | http-first | One of http-first, sse-first, http-only, sse-only. An unrecognised value is ignored with a warning and the default stands. |
| --allow-http | off | Permits a plain-HTTP server URL. Not needed for http://localhost or http://127.0.0.1, which are always allowed; without it any other http:// URL is refused. |
| --header "Name: value" | none | Adds a header to every upstream request. Repeatable. ${VAR} in the value is replaced from the environment, so --header "Authorization: Bearer ${TOKEN}" keeps the secret out of the command line. A value that is not Name:value is ignored with a warning. |
| --request-timeout <seconds> | 300 | Answers a request with a timeout error if the upstream stays silent. 0 disables. |
| --oauth-timeout <seconds> | 30 | Ceiling for a single OAuth round trip — metadata discovery and token refresh. 0 disables. |
| --resource <uri> | none | Sends resource=<uri> on the authorization request, for servers that scope tokens to an audience. |
| --static-oauth-client-info <json\|@file> | none | Skips dynamic client registration and uses the client id and secret you supply. Either inline JSON or @path/to/file.json. |
| --static-oauth-client-info-base64 <base64> | none | The same thing, base64-encoded, for hosts that mangle quotes in a command line. Wins if both are given (with a warning). Invalid base64 or JSON exits with an error rather than falling back. |
| --static-oauth-client-metadata <json\|@file> | none | Overrides the client metadata sent during dynamic registration. Inline JSON or @file. |
A note on secrets. Anything passed on the command line is visible in the process list and is recorded verbatim by hosts that log their MCP server configuration. Prefer
@filefor--static-oauth-client-info, and${VAR}for--header.
Environment variables
| Variable | Default | What it does |
|---|---|---|
| MCP_REMOTE_CONFIG_DIR | ~/.mcp-auth | Root of the credential store. Point it at a throwaway directory to test against a clean store without touching your real one — or at a second one to hold a second account, see below. |
| MCP_REMOTE_SESSION_ID | the parent process id | Identifies the session whose directory holds per-session files. Set it explicitly when launching several instances from one parent, which would otherwise share an id. |
| MCP_REMOTE_SESSION_ISOLATION | isolation on | 0 or false turns per-session isolation off, putting every session back in one shared directory. Any other value, including unset, leaves isolation on. |
Credentials that outlive a session — tokens.json, client_info.json — live in
<store>/shared/ regardless, which is what lets an upgrade keep you logged in. Only per-session
state (the PKCE verifier, the debug log) lives under the session directory.
Running two accounts against the same server
The credential slot is keyed by the server URL within one store root, and the token lives in
<store>/shared/ precisely so that a new session does not cost you a login. That is also what
decides which switch gives you a second account — and it is not the one most people reach for.
MCP_REMOTE_CONFIG_DIR is the switch that does it. A second store root means a second
credential slot, a second lockfile and therefore a login window of its own:
"appengine-second-account": {
"command": "npx",
"args": ["-y", "@computec/mcp-remote@latest", "https://appengine.example/mcp"],
"env": { "MCP_REMOTE_CONFIG_DIR": "/home/you/.mcp-auth-second" }
}MCP_REMOTE_SESSION_ID is not. Per-session isolation keeps per-session state apart and stops
an established session from adopting a token issued to somebody else — but a session that has no
token of its own yet seeds from <store>/shared/, so a fresh session id on its own lands you
silently back on whichever account logged in last.
Once each session does hold its own token they stay apart: a shared copy is adopted only when it
is newer and belongs to the same user (iss|sub|azp), so a colleague's refresh never moves your
session onto their data. The SAP company is chosen per MCP session (SetCompany) rather than
carried by the token, so none of this affects which company you are working in.
Further reading
Platform documentation, including how AppEngine issues the OAuth client this proxy authenticates with: https://learn.computec.one/
Usage
The enhancements are transparent to existing code:
// Existing code continues to work unchanged
const provider = new NodeOAuthClientProvider(options)
const tokens = await provider.tokens() // Now automatically refreshes if needed
// New diagnostic capability
const validity = await provider.checkTokenValidity()
console.log('Token expires in:', validity.timeLeftSeconds, 'seconds')Token Storage Format
Tokens are now stored with an additional issued_at field:
{
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"issued_at": 1643723400000
}Debugging
Enable debug mode to see detailed token refresh information:
npx tsx proxy.ts https://your-server.com --debugDebug logs include:
- Token expiration calculations
- Refresh attempt details
- Success/failure notifications
- Timing information
Error Handling
The system handles various failure scenarios:
- No refresh token: Clears expired tokens and forces re-auth
- Refresh endpoint unavailable: Falls back to full re-authentication
- Network failures: Retries based on underlying HTTP client behavior
- Invalid refresh token: Clears all tokens and forces re-auth
Migration
Existing token files will continue to work:
- A stored token carrying a JWT
expclaim is judged by it, whateverissued_atsays - An opaque token stored without an
issued_atcannot be dated, so it is treated as spent and refreshed on first use - First token refresh will add the
issued_attimestamp - No manual migration required
