Metadata-Version: 2.5
Name: trialmatch-mcp-bridge
Version: 0.1.12
Summary: Local stdio<->HTTPS bridge for Claude Desktop, per THI-882. Auth + relay only — no tool logic; the real MCP server lives in ../src/trialmatch_criteria_mcp, deployed to AgentCore Runtime behind the Gateway this bridge talks to.
Requires-Python: >=3.12
Requires-Dist: httpx2>=2.12
Requires-Dist: httpx>=0.27
Requires-Dist: mcp<3,>=2.0
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: pydantic>=2.7
Description-Content-Type: text/markdown

# 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/`](../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:

```bash
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](#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:

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

1. **A CLI flag**, always wins outright: `--gateway-url`, `--cognito-domain`, `--client-id`,
   `--scopes`, `--redirect-port`, `--credentials-path`, `--tenant-id`.
2. **A `TRIALMATCH_BRIDGE_*` env var** already set in the shell — e.g. `TRIALMATCH_BRIDGE_CLIENT_ID`.

```bash
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.

## 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](#configuration) above):

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

```json
{
  "mcpServers": {
    "trialmatch-criteria": {
      "command": "uv",
      "args": [
        "run", "--directory", "/absolute/path/to/TrialMatch-Criteria-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](https://github.com/anthropics/claude-code/issues/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](https://github.com/anthropics/claude-code/issues/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.

## 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 `main`
that touches `bridge/**`: bumps the PATCH version (computed from PyPI's own current "latest",
not from anything in this repo) and publishes it.

Gated behind the `pypi` GitHub Environment (repo Settings → Environments → `pypi`), which has a
deployment-branch policy restricting it to `main` only — 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):
1. Create the `pypi` GitHub Environment with a deployment-branch policy restricting it to
   `main` (done via `gh api repos/Third-Opinion/TrialMatch-Criteria-MCP/environments/pypi`, no
   UI needed — see git history for the exact call).
2. A PyPI account with access registers a pending trusted publisher at
   https://pypi.org/manage/account/publishing/ for project `trialmatch-mcp-bridge`, owner
   `Third-Opinion`, repo `TrialMatch-Criteria-MCP`, workflow `ci.yml`, environment name `pypi`.

## Test

```bash
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.
