mcp-ferry 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ os: [ubuntu-latest, macos-latest]
18
+ runs-on: ${{ matrix.os }}
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0 # hatch-vcs needs tags/history for the version
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v5
26
+ with:
27
+ enable-cache: true
28
+
29
+ # uv run provisions a managed Python 3.14 (per requires-python) and a
30
+ # project venv with the dev extra. No --system, so it sidesteps the
31
+ # runners' externally-managed (PEP 668) interpreters.
32
+ - name: Ruff
33
+ run: uv run --extra dev ruff check .
34
+
35
+ - name: Pyright
36
+ run: uv run --extra dev pyright src/mcp_ferry
37
+
38
+ - name: Tests
39
+ run: uv run --extra dev pytest -q
@@ -0,0 +1,63 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ with:
13
+ fetch-depth: 0 # hatch-vcs derives the version from the tag
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v5
17
+
18
+ - name: Build sdist + wheel
19
+ run: uv build
20
+
21
+ - name: Check metadata
22
+ run: uvx twine check dist/*
23
+
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write # PyPI Trusted Publishing (OIDC); no API token stored
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+
41
+ - name: Publish to PyPI
42
+ uses: pypa/gh-action-pypi-publish@release/v1
43
+
44
+ github-release:
45
+ needs: publish
46
+ runs-on: ubuntu-latest
47
+ permissions:
48
+ contents: write
49
+ steps:
50
+ - uses: actions/download-artifact@v4
51
+ with:
52
+ name: dist
53
+ path: dist/
54
+
55
+ - name: Create GitHub release
56
+ env:
57
+ GH_TOKEN: ${{ github.token }}
58
+ run: >
59
+ gh release create "${{ github.ref_name }}"
60
+ --repo "${{ github.repository }}"
61
+ --title "${{ github.ref_name }}"
62
+ --generate-notes
63
+ dist/*
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
10
+ # build-time, written by hatch-vcs
11
+ src/mcp_ferry/_version.py
12
+ uv.lock
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
4
+ versions follow [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0]
9
+
10
+ Initial release.
11
+
12
+ - Bridge any number of local stdio MCP servers to one public HTTPS hostname.
13
+ - Streamable HTTP transport (one route per MCP), hardened stdio supervision:
14
+ reader-death restart, bounded request timeout, 16 MiB line buffer, graceful
15
+ stop.
16
+ - `cloudflared` Named Tunnel lifecycle via pycloudflared.
17
+ - `ferry setup` idempotent wizard: tunnel, DNS, Google IdP, Access app with
18
+ Managed OAuth (hosted-client redirect allowlist incl. Claude + ChatGPT),
19
+ email allow-list policy. Account/zone IDs and Google creds settable by flag,
20
+ env, or config for a minimal-permission token and non-interactive runs.
21
+ - `ferry install` LaunchAgent for auto-start at login with restart-on-crash.
22
+ - CLI: `init`, `run`, `setup`, `install`, `uninstall`, `status`, `logs`.
23
+
24
+ [Unreleased]: https://github.com/dalberto/mcp-ferry/compare/v0.1.0...HEAD
25
+ [0.1.0]: https://github.com/dalberto/mcp-ferry/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Darlin Alberto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,478 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-ferry
3
+ Version: 0.1.0
4
+ Summary: Bridge local stdio MCP servers to public HTTPS behind Cloudflare Access Managed OAuth
5
+ Project-URL: Homepage, https://github.com/dalberto/mcp-ferry
6
+ Project-URL: Repository, https://github.com/dalberto/mcp-ferry
7
+ Project-URL: Issues, https://github.com/dalberto/mcp-ferry/issues
8
+ Project-URL: Changelog, https://github.com/dalberto/mcp-ferry/blob/main/CHANGELOG.md
9
+ Author: Darlin Alberto
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cloudflare-access,cloudflare-tunnel,mcp,model-context-protocol,oauth,proxy,stdio
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: Proxy Servers
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.14
23
+ Requires-Dist: cloudflare<6,>=5.0
24
+ Requires-Dist: httpx>=0.28
25
+ Requires-Dist: pycloudflared>=0.2
26
+ Requires-Dist: pydantic>=2.10
27
+ Requires-Dist: rich>=13.9
28
+ Requires-Dist: sse-starlette>=2.2
29
+ Requires-Dist: starlette>=0.46
30
+ Requires-Dist: typer>=0.15
31
+ Requires-Dist: uvicorn>=0.34
32
+ Provides-Extra: dev
33
+ Requires-Dist: pyright>=1.1; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.25; extra == 'dev'
35
+ Requires-Dist: pytest>=8.3; extra == 'dev'
36
+ Requires-Dist: ruff>=0.9; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # mcp-ferry
40
+
41
+ [![CI](https://github.com/dalberto/mcp-ferry/actions/workflows/ci.yml/badge.svg)](https://github.com/dalberto/mcp-ferry/actions/workflows/ci.yml)
42
+ [![PyPI](https://img.shields.io/pypi/v/mcp-ferry)](https://pypi.org/project/mcp-ferry/)
43
+ [![Python](https://img.shields.io/pypi/pyversions/mcp-ferry)](https://pypi.org/project/mcp-ferry/)
44
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
45
+
46
+ Some MCP servers only speak stdio. They run as a subprocess on your machine
47
+ because they read local data: your notes, your messages, your files. That's
48
+ fine when the MCP client runs on the same machine. It falls apart the moment
49
+ you want that tool from your phone, a browser, or an assistant running
50
+ somewhere else. The server has to sit next to the data; the client usually
51
+ doesn't.
52
+
53
+ mcp-ferry is the bridge between them. It puts a local stdio MCP server behind
54
+ a public, authenticated HTTPS URL, so any remote client can reach it while the
55
+ server keeps running right next to your data. One Cloudflare tunnel fronts as
56
+ many MCPs as you want, and adding another is one config block: no new tunnel,
57
+ no new sign-in.
58
+
59
+ ## How it works
60
+
61
+ ```
62
+ MCP client ──HTTPS──► Cloudflare Edge ──Tunnel──► your machine
63
+ │ │
64
+ Managed OAuth + mcp-ferry HTTP server
65
+ Google sign-in ├── /bear ──► bearcli mcp-server (stdio)
66
+ ├── /things ► things-mcp (stdio)
67
+ └── /…
68
+ ```
69
+
70
+ - `mcp-ferry` runs an HTTP server (Streamable HTTP transport) on localhost.
71
+ - Each `/<path>` proxies JSON-RPC frames to one long-lived stdio MCP subprocess.
72
+ - A `cloudflared` Named Tunnel exposes that local server at `https://<hostname>`.
73
+ - Cloudflare Access protects the hostname with [Managed OAuth][1]: Cloudflare
74
+ acts as a full OAuth 2.1 authorization server (PKCE + RFC 7591 dynamic client
75
+ registration), so a remote MCP client can discover and authenticate on its own,
76
+ with no manually-registered client credentials.
77
+ - Google is the identity provider behind Access; only the emails you allow can
78
+ sign in.
79
+
80
+ Any MCP client that supports remote servers over HTTP with OAuth works. Nothing
81
+ here is client-specific.
82
+
83
+ [1]: https://blog.cloudflare.com/managed-oauth-for-access/
84
+
85
+ ## Alternatives considered
86
+
87
+ mcp-ferry didn't start from scratch. The first plan was to take an existing
88
+ stdio-to-HTTP proxy and bolt the rest on by hand. Three projects are worth
89
+ knowing, and each is the better choice for a job mcp-ferry isn't trying to do:
90
+
91
+ - [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) (Python) is a clean,
92
+ focused stdio↔Streamable HTTP/SSE bridge. If you want just the transport
93
+ shim and you'll handle exposure and auth yourself, it's the leanest option.
94
+ - [supergateway](https://github.com/supercorp-ai/supergateway) (Node) does the
95
+ same job in the Node ecosystem and adds WebSocket transport. Reach for it if
96
+ your tooling is Node or you need WS.
97
+ - [mcp-remote](https://github.com/geelen/mcp-remote) goes the other direction:
98
+ it lets a stdio-only client talk to an already-remote server. Different
99
+ problem, and the right answer when that's the one you have.
100
+
101
+ All three are good at the transport. None of them do the rest: a public
102
+ hostname, Cloudflare Access with Managed OAuth so remote clients authenticate
103
+ without a pre-registered client, several MCPs behind one tunnel, a launchd
104
+ service so it survives a reboot, and a wizard that provisions the Cloudflare
105
+ side idempotently. mcp-ferry is those pieces assembled and hardened, not a
106
+ faster proxy. If you only need stdio↔HTTP, use one of the above. If you want
107
+ "expose a local MCP to the internet, behind Google sign-in, in a few
108
+ commands," that's the gap this fills.
109
+
110
+ ## Install
111
+
112
+ Requires Python 3.14+.
113
+
114
+ ```shell
115
+ uv tool install mcp-ferry # or: pipx install mcp-ferry
116
+ ```
117
+
118
+ From source (for development):
119
+
120
+ ```shell
121
+ git clone https://github.com/dalberto/mcp-ferry
122
+ cd mcp-ferry
123
+ uv tool install . # or: pipx install .
124
+ ```
125
+
126
+ ## Setup
127
+
128
+ **Prerequisite:** the domain you'll use must already be an **active Cloudflare
129
+ zone** in the account your API token belongs to: the registrable apex
130
+ (e.g. `example.com`, covering `bridge.example.com`) added to Cloudflare with its
131
+ nameservers delegated and the zone Active. `ferry setup` creates a DNS record
132
+ *inside* an existing zone; it does not add a site to Cloudflare or change
133
+ registrar nameservers. If your domain isn't on Cloudflare yet: add the site in
134
+ the Cloudflare dashboard → point your registrar's nameservers at Cloudflare →
135
+ wait for the zone to go Active, then run the wizard. There is no
136
+ Cloudflare-assigned-domain fallback that preserves authentication. See
137
+ [Why a domain is required](#why-a-domain-is-required).
138
+
139
+ Four steps: scaffold the config, get two credentials manually, then run the
140
+ wizard. Everything else is provisioned by `ferry setup`.
141
+
142
+ ### 1. Create and edit the config
143
+
144
+ ```shell
145
+ ferry init # writes ~/.config/mcp-ferry/config.toml
146
+ $EDITOR ~/.config/mcp-ferry/config.toml
147
+ ```
148
+
149
+ Set `bridge.hostname`, `cloudflare.tunnel_name`, and at least one `[[mcps]]`
150
+ block. The hostname you pick here is referenced in step 3.
151
+
152
+ ### 2. Cloudflare API token
153
+
154
+ 1. Open https://dash.cloudflare.com/profile/api-tokens.
155
+ 2. Click **Create Token** → **Create Custom Token**.
156
+ 3. Give it a name like `mcp-ferry`.
157
+ 4. Add these permissions:
158
+ - `Account` ▸ `Cloudflare Tunnel` ▸ **Edit**
159
+ - `Account` ▸ `Access: Apps and Policies` ▸ **Edit**
160
+ - `Account` ▸ `Access: Identity Providers` ▸ **Edit**
161
+ - `Zone` ▸ `DNS` ▸ **Edit**
162
+ - `Account` ▸ `Account Settings` ▸ **Read**: only needed if you let the
163
+ wizard auto-discover your account. Skip it if you pass `--account-id`
164
+ (see below); without it **and** without `--account-id`, setup fails with
165
+ "no Cloudflare accounts visible to this token".
166
+ 5. Account resources: include your account.
167
+ 6. Zone resources: include the specific zone hosting your hostname.
168
+ 7. Create the token and copy it. Treat it like a password.
169
+
170
+ To run with the **minimal** token (just the four Edit permissions, no
171
+ `Account Settings: Read`), tell the wizard the IDs explicitly instead of having
172
+ it discover them. Get them from the Cloudflare dashboard: the account id is in
173
+ the dashboard URL; the zone id is on the zone's **Overview** page. Then either
174
+ put them in `config.toml`:
175
+
176
+ ```toml
177
+ [cloudflare]
178
+ tunnel_name = "mcp-ferry"
179
+ account_id = "<account-id>"
180
+ zone_id = "<zone-id>"
181
+ ```
182
+
183
+ or pass them per-run (also honored via `CLOUDFLARE_ACCOUNT_ID` /
184
+ `CLOUDFLARE_ZONE_ID`):
185
+
186
+ ```shell
187
+ ferry setup --account-id <account-id> --zone-id <zone-id> --email you@example.com
188
+ ```
189
+
190
+ Precedence is flag/env → `config.toml` → SDK discovery, so the auto-discovery
191
+ path still works unchanged if you'd rather not bother.
192
+
193
+ ### 3. Google OAuth client
194
+
195
+ The Access identity provider needs a Google OAuth client ID + secret.
196
+
197
+ 1. Open https://console.cloud.google.com/ and create a project (or reuse one).
198
+ 2. **APIs & Services** ▸ **OAuth consent screen**:
199
+ - User type: **External**.
200
+ - Fill in app name, support email, developer contact email.
201
+ - You can leave the scopes / test-users sections at their defaults.
202
+ 3. **APIs & Services** ▸ **Credentials** ▸ **Create Credentials** ▸ **OAuth client ID**:
203
+ - Application type: **Web application**.
204
+ - Name: `mcp-ferry` (or anything).
205
+ - **Authorized redirect URIs**: add exactly
206
+ `https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback`.
207
+
208
+ Find `<your-team>` in the Cloudflare Zero Trust dashboard at
209
+ **Settings** ▸ **Custom Pages** ▸ team domain. (If you've never used Zero
210
+ Trust on this account, the dashboard will prompt you to pick the team slug.)
211
+ 4. Click **Create** and copy the **Client ID** and **Client secret**.
212
+
213
+ ### 4. Run the wizard
214
+
215
+ ```shell
216
+ ferry setup --email you@example.com
217
+ ```
218
+
219
+ Allow more than one person by repeating `--email` or comma-separating:
220
+
221
+ ```shell
222
+ ferry setup --email you@example.com --email teammate@example.com
223
+ ferry setup --email "you@example.com, teammate@example.com"
224
+ ```
225
+
226
+ The wizard:
227
+ - prompts for the Cloudflare API token (or reads `CLOUDFLARE_API_TOKEN`)
228
+ - prompts for the Google client ID + secret
229
+ - creates the tunnel, writes credentials to `~/.cloudflared/<tunnel-id>.json`
230
+ - creates a CNAME for your hostname pointing at the tunnel
231
+ - creates the Google identity provider in Cloudflare Access
232
+ - creates the Access application with Managed OAuth enabled
233
+ - creates a single allow-list policy for the emails you passed
234
+ - updates `config.toml` to point `cloudflare.credentials_file` at the new file
235
+
236
+ The wizard is idempotent. Re-running with the same inputs is a no-op.
237
+
238
+ **The allow-list is declarative.** There is one policy (`mcp-ferry allow-list`)
239
+ and the `--email` flags are the source of truth: each `ferry setup` run rewrites
240
+ its include rules to exactly the emails you pass. Add or remove someone by
241
+ re-running with the new flag set. Do **not** hand-edit this policy in the
242
+ Cloudflare dashboard, because the next run overwrites it. (Other policies on the
243
+ app are left untouched; only `mcp-ferry allow-list` is managed.)
244
+
245
+ ## Connect a client
246
+
247
+ The bridge must be **running** first (`ferry run`, or `ferry install` for a
248
+ LaunchAgent). Cloudflare Access + OAuth live at the edge, so sign-in can
249
+ *appear* to succeed even when the bridge is down, but the MCP session then
250
+ fails because there's no origin behind the tunnel. Confirm it's up with the
251
+ checks in [Verifying the server](#verifying-the-server) before debugging the
252
+ client.
253
+
254
+ In your MCP client, add a remote/HTTP MCP server pointing at the **MCP path**,
255
+ not the bare host:
256
+
257
+ ```
258
+ https://<your-hostname>/<mcp-path> e.g. https://mcp-ferry.example.com/bear
259
+ ```
260
+
261
+ The host root has no route and 404s; only the configured `[[mcps]]` paths and
262
+ `/healthz` exist. Pointing a client at the bare hostname breaks the connection
263
+ in confusing ways. The first connection redirects to Cloudflare Access → Google;
264
+ after that the session is reused. OAuth-capable clients self-register via dynamic
265
+ client registration, so there's no client ID to paste. That's what Managed OAuth
266
+ is for.
267
+
268
+ After any re-run of `ferry setup` (it reconciles the Access app + IdP), **remove
269
+ and re-add the connector** in your client so it re-discovers and re-registers.
270
+ A registration cached against an earlier provisioning is the usual cause of
271
+ "auth succeeds, then the session errors" (e.g. `code: Field required`).
272
+
273
+ ### Which clients work out of the box
274
+
275
+ | Client | Callback type | Covered by |
276
+ |---|---|---|
277
+ | Claude (web / desktop / mobile) | hosted `https://claude.ai/...` | default allowlist |
278
+ | ChatGPT (developer mode) | hosted `https://chatgpt.com/...` | default allowlist |
279
+ | Claude Code, Codex CLI, Cursor, VS Code, MCP Inspector | loopback `http://localhost:<port>` / `127.0.0.1` | localhost/loopback flags (automatic) |
280
+
281
+ **Hosted** clients send a fixed public callback URL, which Managed OAuth only
282
+ permits if it's in the app's allowed-redirect-URI list. `ferry setup` provisions
283
+ Claude and ChatGPT by default. **CLI/editor** clients use an ephemeral loopback
284
+ redirect, which the wizard always allows via the
285
+ `allow_any_on_localhost`/`allow_any_on_loopback` flags, so there's no per-client
286
+ config. That's why MCP Inspector works with zero setup.
287
+
288
+ If a hosted client's callback isn't in the list, Cloudflare rejects the
289
+ authorization with `Redirect URI not allowed by application configuration`
290
+ (and the client then reports a downstream `code: Field required`). Add it:
291
+
292
+ ```shell
293
+ ferry setup --allowed-redirect-uri https://claude.ai/api/mcp/auth_callback \
294
+ --allowed-redirect-uri https://some-other-host/oauth/callback
295
+ ```
296
+
297
+ `--allowed-redirect-uri` is repeatable and **replaces** the default list (so
298
+ include the ones you still want). It can also live in `config.toml`:
299
+
300
+ ```toml
301
+ [cloudflare]
302
+ allowed_redirect_uris = [
303
+ "https://claude.ai/api/mcp/auth_callback",
304
+ "https://chatgpt.com/connector_platform_oauth_redirect",
305
+ ]
306
+ ```
307
+
308
+ Precedence: `--allowed-redirect-uri` → `config.toml` → built-in defaults.
309
+
310
+ Note: newer ChatGPT generates a **per-connector** callback URL. The default
311
+ entry works for many setups, but if ChatGPT's connector screen shows a
312
+ different "Redirect" value, add that exact URL with `--allowed-redirect-uri`
313
+ and re-run `ferry setup` (the Access app is reconciled, so the new list takes
314
+ effect), then re-add the connector.
315
+
316
+ ## Verifying the server
317
+
318
+ Before blaming the client, prove the server itself is correct. These three
319
+ unauthenticated probes need no browser and pinpoint exactly where a break is:
320
+
321
+ ```shell
322
+ H=https://<your-hostname>
323
+
324
+ # 1. MCP path must challenge with 401 + WWW-Authenticate (not 200, not 404):
325
+ curl -sS -i -X POST -H 'content-type: application/json' \
326
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' "$H/<mcp-path>"
327
+
328
+ # 2. Protected-resource metadata (also names your real auth server):
329
+ curl -sS "$H/.well-known/oauth-protected-resource"
330
+
331
+ # 3. Authorization-server metadata (endpoints + DCR + PKCE support):
332
+ curl -sS "$H/.well-known/oauth-authorization-server" | python3 -m json.tool
333
+ ```
334
+
335
+ Expected:
336
+
337
+ - Probe 1 → `HTTP/2 401` with `www-authenticate: Bearer ... resource_metadata=...`.
338
+ `200` means Access isn't protecting the host; `404` means wrong path or the
339
+ bridge is down.
340
+ - Probe 2 → `200` JSON like
341
+ `{"resource":"https://...","authorization_servers":["https://<TEAM>.cloudflareaccess.com"]}`.
342
+ **That `<TEAM>` value is the source of truth for your Google redirect URI.**
343
+ It must be `https://<TEAM>.cloudflareaccess.com/cdn-cgi/access/callback`
344
+ exactly. Read it here; do not guess the team slug.
345
+ - Probe 3 → `200` JSON containing `authorization_endpoint`, `token_endpoint`,
346
+ `registration_endpoint`, `response_types_supported: ["code"]`, and
347
+ `code_challenge_methods_supported`. If `registration_endpoint` is absent,
348
+ Managed OAuth isn't enabled on the Access app.
349
+
350
+ ### End-to-end with MCP Inspector
351
+
352
+ The probes prove the server config; **MCP Inspector** proves the whole
353
+ authenticated path: dynamic client registration, the browser OAuth round trip,
354
+ and an actual `initialize` + `tools/list`. Crucially it's a *fresh* client with
355
+ its own registration, so it isolates server problems from a stale registration
356
+ cached in your real client (Claude, etc.).
357
+
358
+ ```shell
359
+ npx @modelcontextprotocol/inspector
360
+ ```
361
+
362
+ In the Inspector UI: set **Transport** to `Streamable HTTP`, **URL** to
363
+ `https://<your-hostname>/<mcp-path>` (include the path), then **Connect**. It
364
+ opens a browser for the Cloudflare Access → Google sign-in, completes the
365
+ code + PKCE exchange, and lists the MCP's tools.
366
+
367
+ Interpreting the result:
368
+
369
+ - **Inspector connects and lists tools** → the server is fully correct. Any
370
+ failure in your real client is client-side: remove and re-add the connector so
371
+ it re-runs discovery + registration against the current server.
372
+ - **Inspector fails the same way** → the break is server/Cloudflare side and now
373
+ reproducible locally. Inspector shows each OAuth step (discovery, registration,
374
+ authorize, token) and the exact error. Debug from whichever step fails.
375
+
376
+ This is the fastest way to answer "is it the server or the client?" Start
377
+ here whenever a client connects but the session misbehaves.
378
+
379
+ ## Auto-start at login
380
+
381
+ ```shell
382
+ ferry install # installs and loads a LaunchAgent
383
+ ferry status # check it's running
384
+ ferry logs -f # tail the bridge log
385
+ ```
386
+
387
+ Logs live at `~/Library/Logs/mcp-ferry/`. To remove: `ferry uninstall`.
388
+
389
+ ## Adding more MCPs
390
+
391
+ Edit `config.toml` and append another `[[mcps]]` block:
392
+
393
+ ```toml
394
+ [[mcps]]
395
+ name = "things"
396
+ path = "/things"
397
+ command = "uvx things-mcp"
398
+ ```
399
+
400
+ Restart the bridge (`launchctl kickstart -k gui/$UID/dev.ascention.mcp-ferry`
401
+ or just `ferry uninstall && ferry install`). The new MCP appears at
402
+ `https://<hostname>/things`. No new tunnel, no new Access app needed.
403
+
404
+ ## Changing your hostname
405
+
406
+ If you edit `bridge.hostname` in `config.toml` and re-run `ferry setup`, the
407
+ tunnel is reused (it's matched by name) but **the DNS record and the Access
408
+ application are matched by the old hostname**, so the wizard creates a *new*
409
+ CNAME and a *new* Access app and leaves the old ones behind. Nothing breaks, but
410
+ you should clean up the orphans manually:
411
+
412
+ 1. Cloudflare dashboard → DNS → delete the old `CNAME` for the previous hostname.
413
+ 2. Zero Trust → Access → Applications → delete the old `mcp-ferry (<old-host>)`
414
+ application.
415
+
416
+ The tunnel, IdP, and `mcp-ferry allow-list` policy are unaffected and don't need
417
+ recreating.
418
+
419
+ ## Why a domain is required
420
+
421
+ mcp-ferry's whole point is an *authenticated* public URL. Authentication comes
422
+ from Cloudflare Access + Managed OAuth, and Access can only be attached to a
423
+ hostname on a Cloudflare zone you control. There is no first-party
424
+ Cloudflare-assigned domain that supports this:
425
+
426
+ - **Quick Tunnels** (`*.trycloudflare.com`) need no domain or account, but they
427
+ cannot carry Cloudflare Access: the URL is unauthenticated and anyone with it
428
+ reaches your MCP. They're also ephemeral (a new random hostname every
429
+ restart). That defeats the security model, so mcp-ferry doesn't use them.
430
+ - `*.cfargotunnel.com` is the tunnel's internal target, not a routable public
431
+ hostname; you can't serve or protect an app on it.
432
+ - Cloudflare doesn't hand out free Access-capable subdomains of its own domains.
433
+
434
+ So the floor is: a domain you own, on Cloudflare's free tier. The cheapest path
435
+ is a ~$10/yr registration (any registrar, or Cloudflare Registrar at cost) added
436
+ as a zone. If you genuinely want an unauthenticated, throwaway tunnel for local
437
+ testing, run `cloudflared tunnel --url http://localhost:<port>` directly. That
438
+ is explicitly *not* what this tool is for, and there's deliberately no `--quick`
439
+ flag that would make it easy to expose your data with no auth.
440
+
441
+ ## Troubleshooting
442
+
443
+ - `ferry status`: LaunchAgent state + per-MCP health from `/healthz`.
444
+ - `ferry logs -f`: tail `stdout`. Pass `--stream err` for `stderr`.
445
+ - `cloudflared` not finding the tunnel: confirm `cloudflare.credentials_file`
446
+ in `config.toml` points at the JSON file the wizard wrote.
447
+ - Access redirect loop: verify the Google authorized redirect URI is exactly
448
+ `https://<team>.cloudflareaccess.com/cdn-cgi/access/callback` and that the
449
+ Access app's allowed IdP is the one the wizard created.
450
+ - Someone can't get in: confirm their email is in the `--email` set you last ran
451
+ `ferry setup` with; the allow-list is rewritten from those flags each run.
452
+ - `Redirect URI not allowed by application configuration` (in the OAuth callback
453
+ URL), surfacing to the client as a downstream `code: Field required`: the
454
+ hosted client's callback isn't in the app's allowed-redirect-URI list. This
455
+ affects hosted clients only (Claude/ChatGPT/etc.); loopback clients like MCP
456
+ Inspector are unaffected, so "Inspector works but Claude doesn't" is the
457
+ signature. Fix: `ferry setup --allowed-redirect-uri <the exact callback>`
458
+ (see [Which clients work out of the box](#which-clients-work-out-of-the-box)),
459
+ then re-add the connector.
460
+ - `code: Field required` with no `Redirect URI not allowed` in the callback: the
461
+ bridge is almost certainly **not running**, or the connector points at the
462
+ bare host instead of the `/<mcp-path>`. Run the
463
+ [verification probes](#verifying-the-server); if they pass, remove and re-add
464
+ the connector to clear a stale registration.
465
+ - Google `redirect_uri_mismatch`: the Google client's authorized redirect URI
466
+ does not match. Get the exact value from probe 2 above
467
+ (`https://<TEAM>.cloudflareaccess.com/cdn-cgi/access/callback`), not your app
468
+ hostname, and not a guessed slug. Google can take a few minutes to honor a
469
+ newly added URI.
470
+ - Google `invalid_client` / "client secret is invalid": the secret in Cloudflare
471
+ doesn't match Google. Verify no truncation/whitespace and that it's the
472
+ secret, not the client ID. Pass it via `--google-client-secret` (or
473
+ `GOOGLE_CLIENT_SECRET`) rather than the prompt; the hidden prompt is the most
474
+ common source of a one-character paste truncation. Re-running `ferry setup`
475
+ re-pushes it (the IdP is reconciled declaratively).
476
+ - Edited the source but the CLI doesn't reflect it (e.g. `No such option`):
477
+ `uv tool install` snapshots the code. Reinstall, or install once with
478
+ `uv tool install --force --editable .` so local edits are picked up live.