{"id":49960149,"url":"https://github.com/dalberto/mcp-ferry","last_synced_at":"2026-05-23T07:00:35.019Z","repository":{"id":358552075,"uuid":"1240596134","full_name":"dalberto/mcp-ferry","owner":"dalberto","description":"Expose local stdio MCP servers at a public, authenticated HTTPS URL via Cloudflare Tunnel + Access Managed OAuth. One tunnel fronts many MCPs.","archived":false,"fork":false,"pushed_at":"2026-05-18T00:21:11.000Z","size":82,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-21T09:39:42.852Z","etag":null,"topics":["claude-code","cloudflare-access","cloudflare-tunnel","mcp","mcp-server"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dalberto.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-16T10:25:37.000Z","updated_at":"2026-05-18T21:21:11.000Z","dependencies_parsed_at":null,"dependency_job_id":"9d898b79-5ef5-4e59-a152-de561b7b6bea","html_url":"https://github.com/dalberto/mcp-ferry","commit_stats":null,"previous_names":["dalberto/mcp-ferry"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/dalberto/mcp-ferry","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dalberto%2Fmcp-ferry","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dalberto%2Fmcp-ferry/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dalberto%2Fmcp-ferry/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dalberto%2Fmcp-ferry/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dalberto","download_url":"https://codeload.github.com/dalberto/mcp-ferry/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dalberto%2Fmcp-ferry/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33386076,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["claude-code","cloudflare-access","cloudflare-tunnel","mcp","mcp-server"],"created_at":"2026-05-18T02:00:49.248Z","updated_at":"2026-05-23T07:00:35.012Z","avatar_url":"https://github.com/dalberto.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mcp-ferry\n\n[![CI](https://github.com/dalberto/mcp-ferry/actions/workflows/ci.yml/badge.svg)](https://github.com/dalberto/mcp-ferry/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/mcp-ferry)](https://pypi.org/project/mcp-ferry/)\n[![Python](https://img.shields.io/pypi/pyversions/mcp-ferry)](https://pypi.org/project/mcp-ferry/)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n\nSome MCP servers only speak stdio. They run as a subprocess on your machine\nbecause they read local data: your notes, your messages, your files. That's\nfine when the MCP client runs on the same machine. It falls apart the moment\nyou want that tool from your phone, a browser, or an assistant running\nsomewhere else. The server has to sit next to the data; the client usually\ndoesn't.\n\nmcp-ferry is the bridge between them. It puts a local stdio MCP server behind\na public, authenticated HTTPS URL, so any remote client can reach it while the\nserver keeps running right next to your data. One Cloudflare tunnel fronts as\nmany MCPs as you want, and adding another is one config block: no new tunnel,\nno new sign-in.\n\n## How it works\n\n```\nMCP client ──HTTPS──► Cloudflare Edge ──Tunnel──► your machine\n                          │                          │\n                    Managed OAuth +              mcp-ferry HTTP server\n                    Google sign-in              ├── /bear ──► bearcli mcp-server (stdio)\n                                                ├── /things ► things-mcp       (stdio)\n                                                └── /…\n```\n\n- `mcp-ferry` runs an HTTP server (Streamable HTTP transport) on localhost.\n- Each `/\u003cpath\u003e` proxies JSON-RPC frames to one long-lived stdio MCP subprocess.\n- A `cloudflared` Named Tunnel exposes that local server at `https://\u003chostname\u003e`.\n- Cloudflare Access protects the hostname with [Managed OAuth][1]: Cloudflare\n  acts as a full OAuth 2.1 authorization server (PKCE + RFC 7591 dynamic client\n  registration), so a remote MCP client can discover and authenticate on its own,\n  with no manually-registered client credentials.\n- Google is the identity provider behind Access; only the emails you allow can\n  sign in.\n\nAny MCP client that supports remote servers over HTTP with OAuth works. Nothing\nhere is client-specific.\n\n[1]: https://blog.cloudflare.com/managed-oauth-for-access/\n\n## Alternatives considered\n\nmcp-ferry didn't start from scratch. The first plan was to take an existing\nstdio-to-HTTP proxy and bolt the rest on by hand. Three projects are worth\nknowing, and each is the better choice for a job mcp-ferry isn't trying to do:\n\n- [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) (Python) is a clean,\n  focused stdio↔Streamable HTTP/SSE bridge. If you want just the transport\n  shim and you'll handle exposure and auth yourself, it's the leanest option.\n- [supergateway](https://github.com/supercorp-ai/supergateway) (Node) does the\n  same job in the Node ecosystem and adds WebSocket transport. Reach for it if\n  your tooling is Node or you need WS.\n- [mcp-remote](https://github.com/geelen/mcp-remote) goes the other direction:\n  it lets a stdio-only client talk to an already-remote server. Different\n  problem, and the right answer when that's the one you have.\n\nAll three are good at the transport. None of them do the rest: a public\nhostname, Cloudflare Access with Managed OAuth so remote clients authenticate\nwithout a pre-registered client, several MCPs behind one tunnel, a launchd\nservice so it survives a reboot, and a wizard that provisions the Cloudflare\nside idempotently. mcp-ferry is those pieces assembled and hardened, not a\nfaster proxy. If you only need stdio↔HTTP, use one of the above. If you want\n\"expose a local MCP to the internet, behind Google sign-in, in a few\ncommands,\" that's the gap this fills.\n\n## Install\n\nRequires Python 3.14+.\n\n```shell\nuv tool install mcp-ferry          # or: pipx install mcp-ferry\n```\n\nFrom source (for development):\n\n```shell\ngit clone https://github.com/dalberto/mcp-ferry\ncd mcp-ferry\nuv tool install .                  # or: pipx install .\n```\n\n## Setup\n\n**Prerequisite:** the domain you'll use must already be an **active Cloudflare\nzone** in the account your API token belongs to: the registrable apex\n(e.g. `example.com`, covering `bridge.example.com`) added to Cloudflare with its\nnameservers delegated and the zone Active. `ferry setup` creates a DNS record\n*inside* an existing zone; it does not add a site to Cloudflare or change\nregistrar nameservers. If your domain isn't on Cloudflare yet: add the site in\nthe Cloudflare dashboard → point your registrar's nameservers at Cloudflare →\nwait for the zone to go Active, then run the wizard. There is no\nCloudflare-assigned-domain fallback that preserves authentication. See\n[Why a domain is required](#why-a-domain-is-required).\n\nFour steps: scaffold the config, get two credentials manually, then run the\nwizard. Everything else is provisioned by `ferry setup`.\n\n### 1. Create and edit the config\n\n```shell\nferry init                      # writes ~/.config/mcp-ferry/config.toml\n$EDITOR ~/.config/mcp-ferry/config.toml\n```\n\nSet `bridge.hostname`, `cloudflare.tunnel_name`, and at least one `[[mcps]]`\nblock. The hostname you pick here is referenced in step 3.\n\n### 2. Cloudflare API token\n\n1. Open https://dash.cloudflare.com/profile/api-tokens.\n2. Click **Create Token** → **Create Custom Token**.\n3. Give it a name like `mcp-ferry`.\n4. Add these permissions:\n   - `Account` ▸ `Cloudflare Tunnel` ▸ **Edit**\n   - `Account` ▸ `Access: Apps and Policies` ▸ **Edit**\n   - `Account` ▸ `Access: Identity Providers` ▸ **Edit**\n   - `Zone` ▸ `DNS` ▸ **Edit**\n   - `Account` ▸ `Account Settings` ▸ **Read**: only needed if you let the\n     wizard auto-discover your account. Skip it if you pass `--account-id`\n     (see below); without it **and** without `--account-id`, setup fails with\n     \"no Cloudflare accounts visible to this token\".\n5. Account resources: include your account.\n6. Zone resources: include the specific zone hosting your hostname.\n7. Create the token and copy it. Treat it like a password.\n\nTo run with the **minimal** token (just the four Edit permissions, no\n`Account Settings: Read`), tell the wizard the IDs explicitly instead of having\nit discover them. Get them from the Cloudflare dashboard: the account id is in\nthe dashboard URL; the zone id is on the zone's **Overview** page. Then either\nput them in `config.toml`:\n\n```toml\n[cloudflare]\ntunnel_name = \"mcp-ferry\"\naccount_id = \"\u003caccount-id\u003e\"\nzone_id = \"\u003czone-id\u003e\"\n```\n\nor pass them per-run (also honored via `CLOUDFLARE_ACCOUNT_ID` /\n`CLOUDFLARE_ZONE_ID`):\n\n```shell\nferry setup --account-id \u003caccount-id\u003e --zone-id \u003czone-id\u003e --email you@example.com\n```\n\nPrecedence is flag/env → `config.toml` → SDK discovery, so the auto-discovery\npath still works unchanged if you'd rather not bother.\n\n### 3. Google OAuth client\n\nThe Access identity provider needs a Google OAuth client ID + secret.\n\n1. Open https://console.cloud.google.com/ and create a project (or reuse one).\n2. **APIs \u0026 Services** ▸ **OAuth consent screen**:\n   - User type: **External**.\n   - Fill in app name, support email, developer contact email.\n   - You can leave the scopes / test-users sections at their defaults.\n3. **APIs \u0026 Services** ▸ **Credentials** ▸ **Create Credentials** ▸ **OAuth client ID**:\n   - Application type: **Web application**.\n   - Name: `mcp-ferry` (or anything).\n   - **Authorized redirect URIs**: add exactly\n     `https://\u003cyour-team\u003e.cloudflareaccess.com/cdn-cgi/access/callback`.\n\n   Find `\u003cyour-team\u003e` in the Cloudflare Zero Trust dashboard at\n   **Settings** ▸ **Custom Pages** ▸ team domain. (If you've never used Zero\n   Trust on this account, the dashboard will prompt you to pick the team slug.)\n4. Click **Create** and copy the **Client ID** and **Client secret**.\n\n### 4. Run the wizard\n\n```shell\nferry setup --email you@example.com\n```\n\nAllow more than one person by repeating `--email` or comma-separating:\n\n```shell\nferry setup --email you@example.com --email teammate@example.com\nferry setup --email \"you@example.com, teammate@example.com\"\n```\n\nThe wizard:\n- prompts for the Cloudflare API token (or reads `CLOUDFLARE_API_TOKEN`)\n- prompts for the Google client ID + secret\n- creates the tunnel, writes credentials to `~/.cloudflared/\u003ctunnel-id\u003e.json`\n- creates a CNAME for your hostname pointing at the tunnel\n- creates the Google identity provider in Cloudflare Access\n- creates the Access application with Managed OAuth enabled\n- creates a single allow-list policy for the emails you passed\n- updates `config.toml` to point `cloudflare.credentials_file` at the new file\n\nThe wizard is idempotent. Re-running with the same inputs is a no-op.\n\n**The allow-list is declarative.** There is one policy (`mcp-ferry allow-list`)\nand the `--email` flags are the source of truth: each `ferry setup` run rewrites\nits include rules to exactly the emails you pass. Add or remove someone by\nre-running with the new flag set. Do **not** hand-edit this policy in the\nCloudflare dashboard, because the next run overwrites it. (Other policies on the\napp are left untouched; only `mcp-ferry allow-list` is managed.)\n\n## Connect a client\n\nThe bridge must be **running** first (`ferry run`, or `ferry install` for a\nLaunchAgent). Cloudflare Access + OAuth live at the edge, so sign-in can\n*appear* to succeed even when the bridge is down, but the MCP session then\nfails because there's no origin behind the tunnel. Confirm it's up with the\nchecks in [Verifying the server](#verifying-the-server) before debugging the\nclient.\n\nIn your MCP client, add a remote/HTTP MCP server pointing at the **MCP path**,\nnot the bare host:\n\n```\nhttps://\u003cyour-hostname\u003e/\u003cmcp-path\u003e      e.g. https://mcp-ferry.example.com/bear\n```\n\nThe host root has no route and 404s; only the configured `[[mcps]]` paths and\n`/healthz` exist. Pointing a client at the bare hostname breaks the connection\nin confusing ways. The first connection redirects to Cloudflare Access → Google;\nafter that the session is reused. OAuth-capable clients self-register via dynamic\nclient registration, so there's no client ID to paste. That's what Managed OAuth\nis for.\n\nAfter any re-run of `ferry setup` (it reconciles the Access app + IdP), **remove\nand re-add the connector** in your client so it re-discovers and re-registers.\nA registration cached against an earlier provisioning is the usual cause of\n\"auth succeeds, then the session errors\" (e.g. `code: Field required`).\n\n### Which clients work out of the box\n\n| Client | Callback type | Covered by |\n|---|---|---|\n| Claude (web / desktop / mobile) | hosted `https://claude.ai/...` | default allowlist |\n| ChatGPT (developer mode) | hosted `https://chatgpt.com/...` | default allowlist |\n| Claude Code, Codex CLI, Cursor, VS Code, MCP Inspector | loopback `http://localhost:\u003cport\u003e` / `127.0.0.1` | localhost/loopback flags (automatic) |\n\n**Hosted** clients send a fixed public callback URL, which Managed OAuth only\npermits if it's in the app's allowed-redirect-URI list. `ferry setup` provisions\nClaude and ChatGPT by default. **CLI/editor** clients use an ephemeral loopback\nredirect, which the wizard always allows via the\n`allow_any_on_localhost`/`allow_any_on_loopback` flags, so there's no per-client\nconfig. That's why MCP Inspector works with zero setup.\n\nIf a hosted client's callback isn't in the list, Cloudflare rejects the\nauthorization with `Redirect URI not allowed by application configuration`\n(and the client then reports a downstream `code: Field required`). Add it:\n\n```shell\nferry setup --allowed-redirect-uri https://claude.ai/api/mcp/auth_callback \\\n            --allowed-redirect-uri https://some-other-host/oauth/callback\n```\n\n`--allowed-redirect-uri` is repeatable and **replaces** the default list (so\ninclude the ones you still want). It can also live in `config.toml`:\n\n```toml\n[cloudflare]\nallowed_redirect_uris = [\n  \"https://claude.ai/api/mcp/auth_callback\",\n  \"https://chatgpt.com/connector_platform_oauth_redirect\",\n]\n```\n\nPrecedence: `--allowed-redirect-uri` → `config.toml` → built-in defaults.\n\nNote: newer ChatGPT generates a **per-connector** callback URL. The default\nentry works for many setups, but if ChatGPT's connector screen shows a\ndifferent \"Redirect\" value, add that exact URL with `--allowed-redirect-uri`\nand re-run `ferry setup` (the Access app is reconciled, so the new list takes\neffect), then re-add the connector.\n\n## Verifying the server\n\nBefore blaming the client, prove the server itself is correct. These three\nunauthenticated probes need no browser and pinpoint exactly where a break is:\n\n```shell\nH=https://\u003cyour-hostname\u003e\n\n# 1. MCP path must challenge with 401 + WWW-Authenticate (not 200, not 404):\ncurl -sS -i -X POST -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}' \"$H/\u003cmcp-path\u003e\"\n\n# 2. Protected-resource metadata (also names your real auth server):\ncurl -sS \"$H/.well-known/oauth-protected-resource\"\n\n# 3. Authorization-server metadata (endpoints + DCR + PKCE support):\ncurl -sS \"$H/.well-known/oauth-authorization-server\" | python3 -m json.tool\n```\n\nExpected:\n\n- Probe 1 → `HTTP/2 401` with `www-authenticate: Bearer ... resource_metadata=...`.\n  `200` means Access isn't protecting the host; `404` means wrong path or the\n  bridge is down.\n- Probe 2 → `200` JSON like\n  `{\"resource\":\"https://...\",\"authorization_servers\":[\"https://\u003cTEAM\u003e.cloudflareaccess.com\"]}`.\n  **That `\u003cTEAM\u003e` value is the source of truth for your Google redirect URI.**\n  It must be `https://\u003cTEAM\u003e.cloudflareaccess.com/cdn-cgi/access/callback`\n  exactly. Read it here; do not guess the team slug.\n- Probe 3 → `200` JSON containing `authorization_endpoint`, `token_endpoint`,\n  `registration_endpoint`, `response_types_supported: [\"code\"]`, and\n  `code_challenge_methods_supported`. If `registration_endpoint` is absent,\n  Managed OAuth isn't enabled on the Access app.\n\n### End-to-end with MCP Inspector\n\nThe probes prove the server config; **MCP Inspector** proves the whole\nauthenticated path: dynamic client registration, the browser OAuth round trip,\nand an actual `initialize` + `tools/list`. Crucially it's a *fresh* client with\nits own registration, so it isolates server problems from a stale registration\ncached in your real client (Claude, etc.).\n\n```shell\nnpx @modelcontextprotocol/inspector\n```\n\nIn the Inspector UI: set **Transport** to `Streamable HTTP`, **URL** to\n`https://\u003cyour-hostname\u003e/\u003cmcp-path\u003e` (include the path), then **Connect**. It\nopens a browser for the Cloudflare Access → Google sign-in, completes the\ncode + PKCE exchange, and lists the MCP's tools.\n\nInterpreting the result:\n\n- **Inspector connects and lists tools** → the server is fully correct. Any\n  failure in your real client is client-side: remove and re-add the connector so\n  it re-runs discovery + registration against the current server.\n- **Inspector fails the same way** → the break is server/Cloudflare side and now\n  reproducible locally. Inspector shows each OAuth step (discovery, registration,\n  authorize, token) and the exact error. Debug from whichever step fails.\n\nThis is the fastest way to answer \"is it the server or the client?\" Start\nhere whenever a client connects but the session misbehaves.\n\n## Auto-start at login\n\n```shell\nferry install                   # installs and loads a LaunchAgent\nferry status                    # check it's running\nferry logs -f                   # tail the bridge log\n```\n\nLogs live at `~/Library/Logs/mcp-ferry/`. To remove: `ferry uninstall`.\n\n## Adding more MCPs\n\nEdit `config.toml` and append another `[[mcps]]` block:\n\n```toml\n[[mcps]]\nname = \"things\"\npath = \"/things\"\ncommand = \"uvx things-mcp\"\n```\n\nRestart the bridge (`launchctl kickstart -k gui/$UID/dev.ascention.mcp-ferry`\nor just `ferry uninstall \u0026\u0026 ferry install`). The new MCP appears at\n`https://\u003chostname\u003e/things`. No new tunnel, no new Access app needed.\n\n## Changing your hostname\n\nIf you edit `bridge.hostname` in `config.toml` and re-run `ferry setup`, the\ntunnel is reused (it's matched by name) but **the DNS record and the Access\napplication are matched by the old hostname**, so the wizard creates a *new*\nCNAME and a *new* Access app and leaves the old ones behind. Nothing breaks, but\nyou should clean up the orphans manually:\n\n1. Cloudflare dashboard → DNS → delete the old `CNAME` for the previous hostname.\n2. Zero Trust → Access → Applications → delete the old `mcp-ferry (\u003cold-host\u003e)`\n   application.\n\nThe tunnel, IdP, and `mcp-ferry allow-list` policy are unaffected and don't need\nrecreating.\n\n## Why a domain is required\n\nmcp-ferry's whole point is an *authenticated* public URL. Authentication comes\nfrom Cloudflare Access + Managed OAuth, and Access can only be attached to a\nhostname on a Cloudflare zone you control. There is no first-party\nCloudflare-assigned domain that supports this:\n\n- **Quick Tunnels** (`*.trycloudflare.com`) need no domain or account, but they\n  cannot carry Cloudflare Access: the URL is unauthenticated and anyone with it\n  reaches your MCP. They're also ephemeral (a new random hostname every\n  restart). That defeats the security model, so mcp-ferry doesn't use them.\n- `*.cfargotunnel.com` is the tunnel's internal target, not a routable public\n  hostname; you can't serve or protect an app on it.\n- Cloudflare doesn't hand out free Access-capable subdomains of its own domains.\n\nSo the floor is: a domain you own, on Cloudflare's free tier. The cheapest path\nis a ~$10/yr registration (any registrar, or Cloudflare Registrar at cost) added\nas a zone. If you genuinely want an unauthenticated, throwaway tunnel for local\ntesting, run `cloudflared tunnel --url http://localhost:\u003cport\u003e` directly. That\nis explicitly *not* what this tool is for, and there's deliberately no `--quick`\nflag that would make it easy to expose your data with no auth.\n\n## Troubleshooting\n\n- `ferry status`: LaunchAgent state + per-MCP health from `/healthz`.\n- `ferry logs -f`: tail `stdout`. Pass `--stream err` for `stderr`.\n- `cloudflared` not finding the tunnel: confirm `cloudflare.credentials_file`\n  in `config.toml` points at the JSON file the wizard wrote.\n- Access redirect loop: verify the Google authorized redirect URI is exactly\n  `https://\u003cteam\u003e.cloudflareaccess.com/cdn-cgi/access/callback` and that the\n  Access app's allowed IdP is the one the wizard created.\n- Someone can't get in: confirm their email is in the `--email` set you last ran\n  `ferry setup` with; the allow-list is rewritten from those flags each run.\n- `Redirect URI not allowed by application configuration` (in the OAuth callback\n  URL), surfacing to the client as a downstream `code: Field required`: the\n  hosted client's callback isn't in the app's allowed-redirect-URI list. This\n  affects hosted clients only (Claude/ChatGPT/etc.); loopback clients like MCP\n  Inspector are unaffected, so \"Inspector works but Claude doesn't\" is the\n  signature. Fix: `ferry setup --allowed-redirect-uri \u003cthe exact callback\u003e`\n  (see [Which clients work out of the box](#which-clients-work-out-of-the-box)),\n  then re-add the connector.\n- `code: Field required` with no `Redirect URI not allowed` in the callback: the\n  bridge is almost certainly **not running**, or the connector points at the\n  bare host instead of the `/\u003cmcp-path\u003e`. Run the\n  [verification probes](#verifying-the-server); if they pass, remove and re-add\n  the connector to clear a stale registration.\n- Google `redirect_uri_mismatch`: the Google client's authorized redirect URI\n  does not match. Get the exact value from probe 2 above\n  (`https://\u003cTEAM\u003e.cloudflareaccess.com/cdn-cgi/access/callback`), not your app\n  hostname, and not a guessed slug. Google can take a few minutes to honor a\n  newly added URI.\n- Google `invalid_client` / \"client secret is invalid\": the secret in Cloudflare\n  doesn't match Google. Verify no truncation/whitespace and that it's the\n  secret, not the client ID. Pass it via `--google-client-secret` (or\n  `GOOGLE_CLIENT_SECRET`) rather than the prompt; the hidden prompt is the most\n  common source of a one-character paste truncation. Re-running `ferry setup`\n  re-pushes it (the IdP is reconciled declaratively).\n- Edited the source but the CLI doesn't reflect it (e.g. `No such option`):\n  `uv tool install` snapshots the code. Reinstall, or install once with\n  `uv tool install --force --editable .` so local edits are picked up live.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdalberto%2Fmcp-ferry","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdalberto%2Fmcp-ferry","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdalberto%2Fmcp-ferry/lists"}