trialmatch-mcp-bridge
A local stdio↔HTTPS bridge for Claude Desktop (THI-882). Deliberately not the real MCP
server — no tool logic here at all. It does PKCE + local-loopback login against Cognito, caches
a refresh token, and relays every MCP call from Desktop to the AgentCore Gateway over HTTPS with
Authorization: Bearer <token> attached. The actual tools live in
../src/trialmatch_criteria_mcp/, deployed to AgentCore
Runtime behind that Gateway.
Setup
Published to PyPI — this is the recommended way to run it, no repo access or git credentials needed at all:
uvx trialmatch-mcp-bridge --login \
--gateway-url https://your-gateway.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp \
--cognito-domain your-pool.auth.us-east-1.amazoncognito.com \
--client-id your-app-client-id
(One-time interactive sign-in; opens your browser. See Configuration below for what these three flags are and where to get real values for them.)
This matters beyond convenience: some MCP hosts run their server subprocesses in a sandbox that
can't reach your normal git credentials — confirmed live, Claude Desktop (an MSIX/Windows-Store
packaged app) runs in an AppContainer that can't access the interactive user's gh-configured
git credential helper, so a git+https:// dependency against this (private) repo fails there
even though it works fine from a plain terminal on the same machine. A public PyPI package has
no credential step to fail.
Alternative, if you're already working in a local clone of this repo:
cd bridge
uv sync
uv run trialmatch-mcp-bridge --login
Either way, this caches a refresh token at ~/.trialmatch-mcp/credentials.json (0600).
Subsequent runs refresh silently — you shouldn't normally need --login again. This refresh
happens mid-session too, not just at startup: the bridge process is long-lived (hours, sometimes
days), far outliving a single Cognito access token's own ~1hr lifetime, so BearerTokenAuth
(auth.py) attaches a freshly-checked token to every request and transparently retries once with
a silently-refreshed one if the Gateway ever rejects a request with 401/403 — a live session
shouldn't need a manual restart just because the token it started with went stale.
If the refresh token itself has expired or been revoked (e.g. after a long weekend, or an
explicit Cognito revocation), that retry falls back to a full interactive PKCE login — opening a
browser mid-session, the same login flow --login runs, on a background thread so it can't
freeze the rest of the relay while it waits. This exists because most MCP hosts (Claude Desktop
included) never restart or re---login this process on their own: without it, an expired refresh
token meant every tool call failed until you noticed and manually disabled/re-enabled the plugin
(which only "fixed" it by respawning the process, landing back on this same interactive fallback
at startup). If nobody's watching for the browser window within 5 minutes (e.g. this bridge is
running headless), the attempt times out and the original failure surfaces as normal — run
--login yourself from an interactive terminal in that case.
This covers a stale token specifically, not a stale MCP session: if the deployed Runtime itself gets redeployed to a new version mid-session, the underlying streamable-HTTP session this process already established can stop being honored regardless of how fresh the bearer token is — that still needs a manual restart of this process (or of Claude Desktop's connection to it) today.
Configuration
--gateway-url, --cognito-domain, and --client-id are required — there's no
TrialMatch-specific default of any kind, so a run with any of them missing fails immediately with
a clear error naming exactly which one to set, rather than silently connecting to the wrong
Gateway. Two sources, highest priority first:
- A CLI flag, always wins outright:
--gateway-url,--cognito-domain,--client-id,--scopes,--redirect-port,--credentials-path,--tenant-id,--log-file. - A
TRIALMATCH_BRIDGE_*env var already set in the shell — e.g.TRIALMATCH_BRIDGE_CLIENT_ID.
trialmatch-mcp-bridge --login \
--gateway-url https://your-gateway.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp \
--cognito-domain your-pool.auth.us-east-1.amazoncognito.com \
--client-id your-app-client-id
TrialMatch's own two known targets' real values are tracked as SSM parameters
(/thirdopinion/plugins/trialmatch-criteria/{dev,prod}/{gateway-url,cognito-domain,client-id},
published by agentcore/cdk/lib/plugin-ssm-parameters.ts on every deploy) and kept in sync with
ThirdOpinion.Plugins' own trialmatch-criteria-dev/-prod plugin.json — see that repo's
scripts/plugin-sync/ — rather than baked into this package. If you're not consuming this
bridge through one of those plugin configs, get the current values from SSM (or from whoever
manages the AgentCore target you're pointing at) instead of guessing at them.
--credentials-path defaults to a location derived automatically from --cognito-domain +
--client-id (a stable hash, not the values themselves) — switching between two different
configs never silently reuses or overwrites the wrong cached token, without needing to think
about --credentials-path at all for the common case of just one config.
--tenant-id (THI-956) scopes redshift-reader queries to a specific tenant for this session,
sent as an x-tenant-id header on every relayed request. The Gateway's shared REQUEST
interceptor is what actually decides whether this is honored, server-side, based on your own
token: if your JWT carries is_super_admin=true, whatever you pass here is used verbatim
(omit it to get every tenant, *); for any other caller, this is silently ignored and your own
verified tenant_ids claim is used instead, exactly as if --tenant-id were never passed. This
is unrelated to trialmatch_criteria_mcp's own per-tool-call impersonate_tenant_id parameter
(a different mechanism, for the trialmatch target, enforced by TrialMatch.Api itself, not this
interceptor) — this flag only affects redshift-reader calls.
--log-file defaults to a location derived the same way as --credentials-path (same base
directory, so credentials.json and logs/bridge-<started-at>-<pid>.log for one config sit
underneath it — <started-at> is this process's own start time, <pid> its own PID, neither
shared). Every log line goes there in addition to stderr — not instead of it, since that's what
an MCP host like Claude Desktop captures on its own (%LOCALAPPDATA%\Claude\Logs\mcp-server- <name>.log on Windows) — but a host's own captured copy has no retention policy of its own and
can grow without bound across restarts (observed live: one host's own log for this process was
still sitting there, un-rotated, days after the process that wrote it had exited). Each process's
own file rotates at midnight and keeps 7 days.
The default embeds this process's own start time and PID specifically so two concurrent processes
for the same --cognito-domain/--client-id (e.g. a --login run alongside a live
Desktop-spawned relay, or two Claude Code sessions both open against the same dev/prod target)
never share one log file — TimedRotatingFileHandler isn't safe for that on Windows (reproduced
live: a PermissionError renaming the file at midnight, because a second process still had it
open). PID alone wasn't enough here — Windows recycles PIDs quickly under a workload of many
short-lived subprocesses (this bridge's own usage pattern), so a later process could otherwise
inherit an earlier, already-exited process's still-fresh log file and silently commingle its own
lines into it; the start time makes that collision require matching both, which isn't realistically
reachable.
Since a given process's own file is no longer the only thing standing between you and unbounded
growth, every process also sweeps its log directory — on a background thread, so this never adds
to this process's own MCP-connection startup time — and deletes any bridge-*.log* file untouched
for more than 7 days, not just its own rotated backups, so a file left behind by a process that
already exited still gets cleaned up eventually (by whichever process next starts against that
same config). Pass --log-file explicitly if you want one predictable, shared path instead — at
the cost of reintroducing the multi-writer race if more than one process is ever live against it
at once.
Debugging a live session: since there's no single, predictable filename to tail anymore, this
process logs one INFO line at startup naming its own PID and the exact log_file path it resolved
to — that line lands in the MCP host's own captured stderr copy too (mcp-server-<name>.log on
Windows), so grepping that file for trialmatch-mcp-bridge starting tells you which
bridge-<started-at>-<pid>.log belongs to the session you're looking at, even with several
concurrent bridge processes running against the same target.
Claude Desktop / Claude Code configuration
Add to claude_desktop_config.json (Desktop) or .mcp.json (Code), with the args this Gateway's
own real values (see Configuration above):
{
"mcpServers": {
"trialmatch-criteria": {
"command": "uvx",
"args": [
"trialmatch-mcp-bridge",
"--gateway-url", "https://your-gateway.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp",
"--cognito-domain", "your-pool.auth.us-east-1.amazoncognito.com",
"--client-id", "your-app-client-id"
]
}
}
}
This is exactly the shape ThirdOpinion.Plugins' trialmatch-criteria-dev/-prod plugin.json
already ships, kept current from SSM by that repo's own sync workflow — copy the real values from
there rather than retyping this by hand if you're consuming one of those two targets.
Or, from a local clone:
{
"mcpServers": {
"trialmatch-criteria": {
"command": "uv",
"args": [
"run", "--directory", "/absolute/path/to/TrialMatch-MCP/bridge",
"trialmatch-mcp-bridge",
"--gateway-url", "https://your-gateway.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp",
"--cognito-domain", "your-pool.auth.us-east-1.amazoncognito.com",
"--client-id", "your-app-client-id"
]
}
}
}
(Or point command at the installed trialmatch-mcp-bridge console script directly if you've
installed this package outside a uv-managed venv.)
Not Claude Code's built-in remote-MCP OAuth support (/mcp add <gateway-url> or
.mcp.json's "type": "http" + oauth block) — confirmed live (claude mcp add --transport http ... <gateway-url> against this exact Gateway) that this fails immediately with
Incompatible auth server: does not support dynamic client registration, before PKCE or token
exchange are even reached. Cognito has no OAuth Dynamic Client Registration endpoint (RFC 7591)
at all — a permanent Cognito limitation, not a bug on Cognito's side — and Claude Code's MCP
OAuth client currently has no fallback when the auth server doesn't support DCR, even when a
pre-configured client id is available
(anthropics/claude-code#67258; this
project hit the identical symptom against Cognito, adding to that report's existing Box
repro). (An earlier version of this note cited
anthropics/claude-code#35846 —
Cognito's discovery document not advertising code_challenge_methods_supported — as the reason;
that condition is real and independently confirmed against this pool too, but it was never
actually the failure this bridge exists to route around, since the DCR failure above happens
first.) This bridge sidesteps the whole problem by using a real, pre-registered Cognito App
Client (created via this repo's own CDK stack) and doing PKCE itself — it never attempts DCR in
the first place.
Process lifecycle
This process runs until either side of the relay ends (Claude Desktop/Code closes its stdio connection, or the gateway stream closes) or a process that launched it goes away.
- Stdio / gateway EOF. When either pump's source ends, the whole relay shuts down
(
relay._pump_then_stop). Before this, a closed stdin ended only the stdin pump: the other pump and the watchdog kept the process alive forever with nobody to talk to. (Closing stdin right after sending a request drops that request's in-flight response — fine for MCP over stdio, where the host closing stdin means shutdown, but it affects aprintf '{...}' | trialmatch-mcp-bridgesmoke test.) - Ancestor-chain watchdog. On Windows, Desktop spawns
uvx.exe, which spawnsuv.exe, which spawns thetrialmatch-mcp-bridge.exeshim, which spawns the venvpython.exe, which spawns the interpreter running this code (Windows has noexecveto collapse that chain), and killing the host's immediate child does not kill its descendants. The bridge therefore records the(pid, start time)of the ancestors at the very start of the process (ancestry.py, imported first, before the slow dependency imports) and polls them every ~10s (relay._ancestry_watchdog); if any one is gone, or its pid was reused by a process started later, it exits. It records whatever the chain was — Claude Desktop, Claude Code, a terminal, a script — so running the bridge by hand works the same way (it ends when the shell that launched it does). The session root at the top of the tree (recognised by name:explorer.exeon Windows, init/systemd/launchd elsewhere) is deliberately not watched: it outlives every host and can restart (Explorer restarts are routine) without touching this connection. Fails open: an ancestor that can't be inspected is skipped, and a poll that can't read one doesn't guess. - Guaranteed exit. Cancelling the relay is not enough to end the process: leaving
stdio_server()waits for its stdin reader, an uncancellable worker-thread read that never returns when the pipe is held open by something else — exactly the orphan case. So once the relay has ended, a daemon timer ends the process after a short grace period (5 s), with a second unconditional timer 2 s later in case flushing the logs is itself blocked (relay._arm_hard_exit). The token cache is written atomically, so a hard exit can't leave it half-written.
The earlier version of the watchdog only checked the immediate parent — the venv launcher,
which stays alive for as long as this process does — so it could never fire. Every reconnect of
the connector then left a whole uv.exe + shim + two python.exe chain (4 processes) behind.
Measured on one machine on 2026-10-15: 61 stale chains (244 processes, ~10.7 GB of committed
memory, ~0.9 GB resident) next to the 8 chains still attached to a live Claude.
Publishing
Automatic, via .github/workflows/ci.yml's publish-bridge job — no manual version bump, no
git tags, no stored PyPI token (uses PyPI's OIDC "Trusted Publishing"). On a merge to develop
that touches bridge/**: bumps the PATCH version (computed from PyPI's own current "latest",
not from anything in this repo) and publishes it. (THI-956 removed this job's old
main-triggered path entirely, alongside the bridge's --env/ENV_PRESETS fallback it existed
to keep in sync — every real caller now passes its own values explicitly, so a main push,
a prod infra promotion, never has anything new for PyPI to publish either.)
Gated behind the pypi GitHub Environment (repo Settings → Environments → pypi) — per PyPI's
own Trusted Publishing guidance, a dedicated environment for the publishing workflow is
"strongly encouraged, especially if your repository has maintainers with commit access who
shouldn't have PyPI publishing access." (No pre-release/dev-version channel — deliberately kept
to just this one path; testing a specific in-progress change is simpler done straight from a
local clone, see Setup above, than by publishing and pinning a throwaway version.)
One-time setup (already done, noted here in case the project is ever re-created):
- Create the
pypiGitHub Environment (done viagh api repos/Third-Opinion/TrialMatch-MCP/environments/pypi, no UI needed — see git history for the exact call, from back when this repo was still namedTrialMatch-Criteria-MCP). - A PyPI account with access registers a pending trusted publisher at
https://pypi.org/manage/account/publishing/ for project
trialmatch-mcp-bridge, ownerThird-Opinion, repoTrialMatch-MCP, workflowci.yml, environment namepypi. Not yet updated for the THI-956 repo rename (TrialMatch-Criteria-MCP→TrialMatch-MCP) as of this writing — PyPI has no API for this, someone with account access needs to update it via the URL above, or the next publish attempt fails on an OIDC claim mismatch.
Test
uv run pytest
uv run ruff check . && uv run ruff format --check .
uv run mypy src
Unit tests mock the Cognito token endpoint and the loopback callback — no browser or real
Cognito pool needed to run them. A real end-to-end test still needs an actual browser login
once (--login), since PKCE Authorization Code flow is inherently interactive by design.
Metadata
Release files for trialmatch-mcp-bridge 0.1.18
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| trialmatch_mcp_bridge-0.1.18.tar.gz | 110.9 kB | Without provenance | Details for trialmatch_mcp_bridge-0.1.18.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| trialmatch_mcp_bridge-0.1.18-py3-none-any.whl | Python 3 | none | any | Details for trialmatch_mcp_bridge-0.1.18-py3-none-any.whl |
Total release size: 148.6 kB
Release files / trialmatch_mcp_bridge-0.1.18.tar.gz
| Download URL | trialmatch_mcp_bridge-0.1.18.tar.gz |
|---|---|
| Size | 110.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
33a5503e2d9ecd6e3fd002439946137fdca7267f5cf52b126301cb1deac23990
|
|
BLAKE2b-256 checksum How to use checksums |
5b729ff1c400aead928fae29314e4c2645cd12317326dc70258aa4250c8e7e99
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / trialmatch_mcp_bridge-0.1.18-py3-none-any.whl
| Download URL | trialmatch_mcp_bridge-0.1.18-py3-none-any.whl |
|---|---|
| Size | 37.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
708e296c0e260e95ae771eaca4d93cf86772fd024b5a1f36f32468458d98af4a
|
|
BLAKE2b-256 checksum How to use checksums |
7748b4562aaacdf153ac9962984a955885fd74a8b0ed6481a5e70956756e3a87
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for trialmatch-mcp-bridge 0.1.18
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.