From b3d36a8a32605101bd64691ceaef55ec1b0f5b88 Mon Sep 17 00:00:00 2001 From: MRDula Date: Tue, 21 Apr 2026 19:50:20 -0400 Subject: [PATCH] =?UTF-8?q?fix(router):=20stop=20the=20/mcp=20=E2=86=92=20?= =?UTF-8?q?/mcp/=20redirect=20from=20stripping=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs turned every non-technical install into a 40-minute debugging session: 1. **Starlette auto-redirect.** `app.mount("/mcp", ...)` makes Starlette 307-redirect any POST to /mcp → /mcp/. mcp-remote drops the POST body when following a 307, so the router reported "Missing session ID" on every initialize. 2. **HTTPS → HTTP protocol downgrade.** Behind Railway's TLS-terminating proxy, uvicorn wasn't trusting X-Forwarded-Proto, so the 307's Location header came back http:// instead of https://. Clients that did follow the redirect correctly stripped the Authorization header on protocol downgrade — silent 401s everywhere. Fixes: - **`services/mcp-router/Dockerfile`**: add `--proxy-headers --forwarded-allow-ips='*'` to uvicorn. Starlette now sees the real scheme and any redirect stays HTTPS. - **`services/mcp-router/router/main.py`**: add ASGI middleware that rewrites `/mcp` → `/mcp/` IN-PLACE. No redirect issued at all — clients can POST to either URL and both work. - **Client defaults updated to `/mcp/` (trailing slash)** so even on older router versions the redirect is bypassed: - `plugin/bin/reva-mcp-launch.sh` default URL - `plugin/scripts/desktop-install.sh` `DEFAULT_MCP_URL` - `plugin/skills/revmyengine/SKILL.md` /connect default Also in this commit — fix the `/connect` Cowork sandbox blind spot: When the engine runs inside Claude Cowork, the `Bash` tool writes to an ephemeral sandbox filesystem, NOT the user's real Mac. Credentials written via Bash in Cowork never reach Claude Desktop's launcher, so the plugin stays disconnected while the PM watches us "succeed." Fix: `/connect` now runs the same capability-detection dance as `/heal`. If `mcp__Control_your_Mac__osascript` is present, prefer it over `Bash` — osascript punches through the sandbox and lands the creds on the real Mac's `~/.reva-turbo/state/mcp-credentials.env`. Bumps plugin to v2.1.5 (VERSION, plugin.json, SKILL.md frontmatter, signup footer). Co-Authored-By: Claude Opus 4.7 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/VERSION | 2 +- plugin/bin/reva-mcp-launch.sh | 8 ++- plugin/scripts/desktop-install.sh | 2 +- plugin/skills/revmyengine/SKILL.md | 77 ++++++++++++++++++++++++---- services/mcp-router/Dockerfile | 6 ++- services/mcp-router/router/main.py | 14 +++++ services/mcp-router/router/signup.py | 2 +- 8 files changed, 97 insertions(+), 16 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4c99433..edef9bf 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "reva-turbo", "displayName": "RevAOps Plugin", - "version": "2.1.4", + "version": "2.1.5", "description": "The Rev A Manufacturing PM copilot — full RFQ-to-delivery workflow with CRM, memory, and 48 skills. Paste your key in chat and you're in; bring your own CRM (HubSpot, Salesforce, Attio, Pipedrive) or use the bundled Nakatomi + AutoMem defaults.", "author": { "name": "Rev A Manufacturing / MrDula Solutions", diff --git a/plugin/VERSION b/plugin/VERSION index 7d2ed7c..cd57a8b 100644 --- a/plugin/VERSION +++ b/plugin/VERSION @@ -1 +1 @@ -2.1.4 +2.1.5 diff --git a/plugin/bin/reva-mcp-launch.sh b/plugin/bin/reva-mcp-launch.sh index 04a468c..652bc98 100755 --- a/plugin/bin/reva-mcp-launch.sh +++ b/plugin/bin/reva-mcp-launch.sh @@ -36,8 +36,12 @@ if [ -f "$CRED_FILE" ]; then fi # Fall back to the Rev A production router if the PM hasn't overridden it. -# The /mcp suffix matches the router's mount point in services/mcp-router. -: "${REVA_MCP_URL:=https://mcp-router-production-460a.up.railway.app/mcp}" +# The /mcp/ suffix (TRAILING SLASH is load-bearing) matches the router's +# mount point. Without the slash, Starlette's Mount issues a 307 redirect +# to /mcp/ and mcp-remote drops the POST body (and the Authorization +# header, if the redirect downgrades to HTTP behind Railway's proxy) — +# the classic "Missing session ID" install failure. +: "${REVA_MCP_URL:=https://mcp-router-production-460a.up.railway.app/mcp/}" : "${REVA_API_KEY:=}" # Hand off to mcp-remote. `-y` bypasses the npx prompt, which would hang diff --git a/plugin/scripts/desktop-install.sh b/plugin/scripts/desktop-install.sh index 4dc575a..df73374 100755 --- a/plugin/scripts/desktop-install.sh +++ b/plugin/scripts/desktop-install.sh @@ -32,7 +32,7 @@ set -uo pipefail REPO="mrdulasolutions/RevOps-RevAMfg" TAG="${REVA_RELEASE_TAG:-latest}" -DEFAULT_MCP_URL="https://mcp-router-production-460a.up.railway.app/mcp" +DEFAULT_MCP_URL="https://mcp-router-production-460a.up.railway.app/mcp/" REVA_MCP_URL="${REVA_MCP_URL:-$DEFAULT_MCP_URL}" # Pretty-ish output that still reads fine inside AppleScript's `do shell diff --git a/plugin/skills/revmyengine/SKILL.md b/plugin/skills/revmyengine/SKILL.md index da18d29..fa20a6c 100644 --- a/plugin/skills/revmyengine/SKILL.md +++ b/plugin/skills/revmyengine/SKILL.md @@ -1,7 +1,7 @@ --- name: revmyengine preamble-tier: 1 -version: 2.1.4 +version: 2.1.5 description: | REVA-TURBO master orchestrator for Rev A Manufacturing PM workflow. Routes requests to the correct sub-skill based on context. Chains the @@ -272,15 +272,41 @@ file was written, and wait. Extract the key from the PM's message (grep for `nk_[A-Za-z0-9_-]+`). If the user also provided a router URL, capture that too (look for an -`https://…/mcp` token); otherwise default to the Rev A production URL. +`https://…/mcp` token); otherwise default to +`https://mcp-router-production-460a.up.railway.app/mcp/` (trailing +slash is load-bearing — see note at end of section). -Write credentials to the file the MCP launcher reads on Desktop -startup (`bin/reva-mcp-launch.sh`): +#### ⚠️ CRITICAL: detect the execution environment BEFORE writing + +The credentials file must land on the **user's real Mac** at +`~/.reva-turbo/state/mcp-credentials.env` — that's the file Claude +Desktop's launcher reads on startup. **In Claude Cowork**, the `Bash` +tool writes to an ephemeral sandbox filesystem, *not* the real Mac — +credentials written via Bash inside Cowork never reach Desktop and +the plugin stays disconnected. + +Capability detection (check BEFORE writing creds): + +| Environment | Signal | Write creds via | +|---|---|---| +| **Claude Code** (runs on the Mac itself) | `Bash` tool present AND no `mcp__computer-use__*` / `mcp__Control_your_Mac__*` | Bash — writes land on the real Mac | +| **Claude Cowork** (sandboxed) | Both `Bash` AND `mcp__Control_your_Mac__osascript` present | `mcp__Control_your_Mac__osascript` — punches through the sandbox to the real Mac | +| **Claude Desktop with Bash** (rare) | `Bash` only, no osascript | Bash works; native macOS filesystem | +| **Claude Desktop, no Bash, osascript present** | `mcp__Control_your_Mac__osascript` only | osascript | +| **No shell-capable tool at all** | Filesystem MCP write tools only | Filesystem MCP writing to `$HOME/.reva-turbo/state/mcp-credentials.env` | + +**Rule of thumb:** *if `mcp__Control_your_Mac__osascript` is present, +prefer it over `Bash` for credential writes.* Cowork has both Bash +(sandbox) and osascript (real Mac) — if you use Bash you silently +miss the Mac, and the PM watches you "succeed" while nothing actually +works. + +#### Write creds via Bash (Claude Code / native Desktop) ```bash mkdir -p ~/.reva-turbo/state _KEY="NK_KEY_HERE" # replace with the extracted nk_... value -_URL="MCP_URL_HERE" # replace; default: https://mcp-router-production-460a.up.railway.app/mcp +_URL="MCP_URL_HERE" # default: https://mcp-router-production-460a.up.railway.app/mcp/ cat > ~/.reva-turbo/state/mcp-credentials.env < $HOME/.reva-turbo/state/mcp-credentials.env <<'EOF' +REVA_MCP_URL=https://mcp-router-production-460a.up.railway.app/mcp/ +REVA_API_KEY=NK_KEY_HERE +EOF +chmod 600 $HOME/.reva-turbo/state/mcp-credentials.env" +``` + +Substitute `NK_KEY_HERE` with the actual `nk_...` value **before** +sending to `mcp__Control_your_Mac__osascript`. Never log the key. + +#### Validate the key immediately (same environment) + +Hit the router's `/auth/me` endpoint — proxied through to Nakatomi, +returns the user record on a valid key. Use the **same execution +environment** you used to write the creds so you're validating against +the same network (e.g. osascript if you wrote via osascript, Bash if +you wrote via Bash): ```bash -_BASE="${_URL%/mcp}" +# Bash environments: +_BASE="${_URL%/mcp/}" curl -fsS -H "Authorization: Bearer $_KEY" "$_BASE/auth/me" | head -c 400 + +# osascript environment: +do shell script "curl -fsS -H 'Authorization: Bearer NK_KEY_HERE' 'https://mcp-router-production-460a.up.railway.app/auth/me' 2>&1 | head -c 400" ``` - If `curl` succeeds (HTTP 200, JSON with `email`/`workspace`): tell @@ -317,6 +364,18 @@ curl -fsS -H "Authorization: Bearer $_KEY" "$_BASE/auth/me" | head -c 400 - The file mode is 600 — belt-and-suspenders against any other process on the box. +**Why the trailing slash on `/mcp/` is load-bearing:** the router +mounts FastMCP under `/mcp`. A POST to `/mcp` (no slash) triggers +Starlette's auto-redirect to `/mcp/` as a 307. Some MCP clients +(notably `mcp-remote` ≤ 0.x) drop the request body when following a +307 POST — and if the origin is behind a proxy that doesn't pass +X-Forwarded-Proto through, the redirect target can come back `http://` +instead of `https://`, at which point the Authorization header gets +stripped on protocol downgrade. The router has proxy-header trust +enabled as of v2.1.5 and rewrites `/mcp` → `/mcp/` in middleware, so +both URLs now work server-side — but the credentials file should +always carry the trailing-slash form. Defense in depth. + Do not proceed to Steps 1–4 until the tool call succeeds. ## Trust Level Injection diff --git a/services/mcp-router/Dockerfile b/services/mcp-router/Dockerfile index 6d63318..7b090fc 100644 --- a/services/mcp-router/Dockerfile +++ b/services/mcp-router/Dockerfile @@ -14,4 +14,8 @@ COPY router ./router EXPOSE 8080 # Railway supplies $PORT; default to 8080 for local. -CMD ["sh", "-c", "uvicorn router.main:app --host 0.0.0.0 --port ${PORT:-8080}"] +# --proxy-headers + --forwarded-allow-ips='*' make uvicorn trust Railway's +# X-Forwarded-Proto so any redirect (e.g. Starlette's Mount trailing-slash +# redirect on /mcp → /mcp/) stays HTTPS instead of downgrading to HTTP and +# stripping the Authorization header. +CMD ["sh", "-c", "uvicorn router.main:app --host 0.0.0.0 --port ${PORT:-8080} --proxy-headers --forwarded-allow-ips='*'"] diff --git a/services/mcp-router/router/main.py b/services/mcp-router/router/main.py index cafbc15..a993d7f 100644 --- a/services/mcp-router/router/main.py +++ b/services/mcp-router/router/main.py @@ -52,6 +52,20 @@ async def lifespan(_: FastAPI): yield app = FastAPI(title="REVA MCP Router", version="0.1.0", lifespan=lifespan) + + # Normalize /mcp → /mcp/ IN-PLACE (no 307 redirect). Starlette's Mount + # would otherwise redirect any missing-trailing-slash request, and + # mcp-remote / other MCP clients drop the POST body (and Authorization + # header, if the redirect downgrades to HTTP) when following a 307. + # Rewriting the path at the ASGI layer bypasses that entirely — clients + # can POST to /mcp OR /mcp/ and both work. + @app.middleware("http") + async def normalize_mcp_trailing_slash(request, call_next): + if request.scope["path"] == "/mcp": + request.scope["path"] = "/mcp/" + request.scope["raw_path"] = b"/mcp/" + return await call_next(request) + app.mount("/mcp", mcp.streamable_http_app()) # Self-service signup (GET /signup HTML, POST /signup JSON) diff --git a/services/mcp-router/router/signup.py b/services/mcp-router/router/signup.py index 5da89ae..7517c44 100644 --- a/services/mcp-router/router/signup.py +++ b/services/mcp-router/router/signup.py @@ -491,7 +491,7 @@ def _extract_detail(resp: httpx.Response, fallback: str) -> str:
Rev A Manufacturing · RevAOps - v2.1.4 + v2.1.5