diff --git a/.env.example b/.env.example index 6edea7c4c..2c6c2065a 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,21 @@ SEARXNG_INSTANCE=http://localhost:8080 # Local HTTP setups may use the callback URL inferred by the application. # GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback +# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP) +# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT, +# which is right only when you reach Odysseus directly on that port. Set it for +# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the +# app always listens on 7000 and cannot see the host port map, so the default is +# wrong there whenever APP_PORT is not 7000. +# +# Not for Google MCP servers. Those use Desktop App credentials, and Google only +# accepts loopback redirect URIs for that client type, so a public origin here is +# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install: +# the loopback default is what Google wants, and remote users finish through the +# paste-back page, which never has to load the redirect. +# https://developers.google.com/identity/protocols/oauth2/native-app +# OAUTH_REDIRECT_BASE_URL=https://your-domain.com + # ============================================================ # Misc # ============================================================ diff --git a/build-macos-app.sh b/build-macos-app.sh index 1208a1dce..c76075cac 100755 --- a/build-macos-app.sh +++ b/build-macos-app.sh @@ -73,6 +73,10 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER' INSTALL_DIR="__INSTALL_DIR__" PORT="__PORT__" URL="http://127.0.0.1:${PORT}" +# uvicorn is started with --port below, but APP_PORT is what the app itself +# reads when it needs to build a URL for this instance (internal_api_base(), +# companion pairing, the MCP OAuth callback), so export it as well. +export APP_PORT="$PORT" export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" UVICORN="$INSTALL_DIR/venv/bin/uvicorn" diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 85d872b58..24f65e5cb 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -75,6 +75,11 @@ services: - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} + # Externally reachable origin for MCP OAuth callbacks. The container + # always listens on 7000 and cannot see the host port map above, so + # remote MCP OAuth needs this set whenever the browser reaches + # Odysseus on anything other than http://localhost:7000. + - OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-} # PUID / PGID — the user/group the container drops to before diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index a367be001..fc66234db 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -74,6 +74,11 @@ services: - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} + # Externally reachable origin for MCP OAuth callbacks. The container + # always listens on 7000 and cannot see the host port map above, so + # remote MCP OAuth needs this set whenever the browser reaches + # Odysseus on anything other than http://localhost:7000. + - OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-} # PUID / PGID — the user/group the container drops to before diff --git a/docker-compose.yml b/docker-compose.yml index 39774fb53..38cb654e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,11 @@ services: - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} + # Externally reachable origin for MCP OAuth callbacks. The container + # always listens on 7000 and cannot see the host port map above, so + # remote MCP OAuth needs this set whenever the browser reaches + # Odysseus on anything other than http://localhost:7000. + - OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-} # PUID / PGID — the user/group the container drops to before diff --git a/launch-windows.ps1 b/launch-windows.ps1 index 263d95127..ab0e3542b 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -163,6 +163,10 @@ if (Test-Path $cudaBase) { } # 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH) +# -Port only reaches uvicorn as a flag. Everything that builds a URL for this +# instance - internal_api_base(), companion pairing, the MCP OAuth callback - +# reads APP_PORT, so set it too or they all assume 7000. +$env:APP_PORT = $Port Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port) Write-Host "Press Ctrl+C to stop." Write-Host "" diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index a0ade88b6..94c83f8dd 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -475,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager): return RedirectResponse(auth_url) else: # Remote device — show paste-back page - return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri)) + return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri)) finally: db.close() @@ -612,15 +612,13 @@ def setup_mcp_routes(mcp_manager: McpManager): def _oauth_authorize_page( auth_url: str, server_id: str, - host: str, - redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback", + redirect_uri: str, ) -> str: """Page with Google sign-in link and URL paste-back form for remote access.""" - # Escape values interpolated into the page: `host` comes from the request - # Host header and `server_id` from the OAuth state — neither is trusted. + # Escape values interpolated into the page: `server_id` comes from the OAuth + # state and is not trusted. auth_url = html.escape(auth_url, quote=True) server_id = html.escape(server_id, quote=True) - host = html.escape(host, quote=True) redirect_uri = html.escape(redirect_uri, quote=True) return f"""
@@ -664,7 +662,15 @@ def _oauth_authorize_page( Sign in with Google -