From 904401a7f13085bc24614021db09070a76055bfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 11:26:20 +0000 Subject: [PATCH 1/2] Add provider-declared OAuth bootstrap and run setup before registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth bootstrap: a provider YAML can now declare a one-time browser consent flow with a top-level oauth: block (type: google) naming its client_secret.json, the token file to mint, and the scopes. The new oauth_bootstrap module reuses the existing authorization_code machinery β€” flows register in AuthCodeTokenStore._pending_flows tagged with a kind, URLs publish to the pending-auth banner, and the shared /oauth/callback dispatches non-REST flows back to it β€” and builds the consent URL with PKCE, access_type=offline, and prompt=consent. The exchanged token is written in Credentials.to_json() format so provider code keeps using Credentials.from_authorized_user_file() unchanged, with refresh handled by the google libs at call time. Wiring: a startup oauth-warmup thread surfaces missing tokens in the banner; POST /api/oauth-bootstrap (and a πŸ” Authorize button with a read-only summary in the provider editor) restarts the flow on demand; /api/tools exposes per-provider token status for a list badge; the oauth block round-trips through the structured editor and is validated (including a client_secret_file existence check that points at the Files manager). Also: bootstrap_provider now runs requirements/setup before registration, eliminating the noisy failed-first-attempt tracebacks for code providers that import their declared requirements at module level (the previous install-then-retry behavior is no longer needed). 41 new tests cover the flow begin/complete, token-file format (loaded back via google-auth), refresh-token salvage and consent-hint error, callback dispatch (with REST regression), warm-up, endpoints, round-trip, and validation. https://claude.ai/code/session_01WnK1rtXGHDCNpsycAvxFqC --- Dockerfile | 2 +- README.md | 41 ++++ frontend/app.py | 164 +++++++++++++++- oauth_bootstrap.py | 277 +++++++++++++++++++++++++++ rest_provider.py | 5 + server.py | 74 +++++--- tests/test_frontend.py | 162 ++++++++++++++++ tests/test_oauth_bootstrap.py | 343 ++++++++++++++++++++++++++++++++++ tests/test_server.py | 98 ++++++++-- 9 files changed, 1123 insertions(+), 43 deletions(-) create mode 100644 oauth_bootstrap.py create mode 100644 tests/test_oauth_bootstrap.py diff --git a/Dockerfile b/Dockerfile index 9c59b51..9d098b8 100755 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir uv ENV PATH="/root/.local/bin:$PATH" -COPY server.py config.py process_runner.py builtin_tools.py tool_registry.py rest_provider.py ./ +COPY server.py config.py process_runner.py builtin_tools.py tool_registry.py rest_provider.py oauth_bootstrap.py ./ COPY frontend/ ./frontend/ COPY handlers/ ./handlers/ diff --git a/README.md b/README.md index d318a9b..09906cf 100755 --- a/README.md +++ b/README.md @@ -244,6 +244,47 @@ Config knobs: `MCPPROXY_REST_AUTH_DIR`, `MCPPROXY_OAUTH_REDIRECT_BASE`, response size before truncation; 0 disables), and `MCPPROXY_OAUTH_FLOW_TTL` (seconds an in-flight authorization attempt stays valid; default 600). +### OAuth token-file bootstrap (`oauth:` block) + +Some providers β€” typically **code providers** using Google client libraries β€” need a +user-consent OAuth token *file* (e.g. `gmail_token.json` minted from +`client_secret.json`) rather than header injection. Instead of running +`InstalledAppFlow` on a machine with a browser and copying the files in, declare the +need in the provider YAML and let mcpproxy run the flow: + +```yaml +oauth: + type: google # the only supported type today + client_secret_file: /app/tools/secrets/client_secret.json + token_file: /app/tools/secrets/gmail_token.json + scopes: + - https://www.googleapis.com/auth/gmail.settings.basic + - https://www.googleapis.com/auth/gmail.labels + # optional: prompt (default "consent"), login_hint +``` + +How it works: + +1. Create a Google OAuth client (Desktop **installed** type is easiest) in the Google + Cloud Console, download `client_secret.json`, and upload it via the **πŸ“ Files** + manager (e.g. into `tools/secrets/`). +2. At startup (and via the **πŸ” Authorize** button in the provider editor), mcpproxy + checks `token_file`. If no usable token exists, the consent URL β€” built with PKCE, + `access_type=offline`, and `prompt=consent` β€” appears in the yellow pending-auth + banner. +3. Click it, approve in Google, and the browser is redirected to mcpproxy's + `/oauth/callback`, which exchanges the code and writes `token_file` in exactly the + format `google.oauth2.credentials.Credentials.from_authorized_user_file()` accepts. +4. Your provider code reads the token file as usual; the Google client libraries refresh + the access token automatically from the stored `refresh_token` at call time. If the + token is ever revoked, the πŸ” badge and banner reappear β€” re-authorizing is one click. + +Redirect URI: Desktop ("installed") Google clients accept `http://localhost:8889/oauth/callback` +without registration (browse the UI via localhost when authorizing). "Web" clients must +have the exact URI registered β€” set `MCPPROXY_OAUTH_REDIRECT_BASE` if the UI is served +from a different origin. Note `prompt=consent` is the default because Google only issues +a refresh_token on a full consent screen, not on silent re-approval. + ## Secrets Each tool provider YAML declares its required environment variables under `secrets.env`: diff --git a/frontend/app.py b/frontend/app.py index 1557b4e..bfeb4bb 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -19,6 +19,7 @@ POST /api/files/upload β€” multipart upload (root, path, file) GET /api/files/download β€” download a file (?root=&path=) DELETE /api/files β€” delete a file/dir (?root=&path=&recursive=) +POST /api/oauth-bootstrap β€” begin a provider-declared OAuth consent flow {name} POST /api/restart β€” send SIGTERM to restart server GET /api/config β€” UI feature flags (e.g. web_terminal) WS /ws/terminal β€” interactive PTY terminal (optional ?cmd=…) @@ -318,6 +319,7 @@ def _provider_to_structured(name: str, spec: dict[str, Any]) -> dict[str, Any]: "repo_env_keys": repo_env_keys, "workdir": workdir, "rest": rest_out, + "oauth": dict(spec.get("oauth") or {}), "tools": tools_out, } @@ -375,6 +377,20 @@ def _structured_to_yaml(provider: dict[str, Any]) -> str: if code: spec["code"] = code + "\n" + oauth = provider.get("oauth") or {} + if oauth.get("type"): + oauth_block: dict[str, Any] = {"type": oauth["type"]} + for key in ("client_secret_file", "token_file"): + if (oauth.get(key) or "").strip(): + oauth_block[key] = oauth[key].strip() + scopes = [s for s in (oauth.get("scopes") or []) if s] + if scopes: + oauth_block["scopes"] = scopes + for key in ("prompt", "login_hint"): + if oauth.get(key): + oauth_block[key] = oauth[key] + spec["oauth"] = oauth_block + requirements = [r for r in (provider.get("requirements") or []) if r] if requirements: spec["requirements"] = requirements @@ -461,9 +477,39 @@ def _validate_rest(provider: dict[str, Any]) -> list[str]: return errors +def _validate_oauth(provider: dict[str, Any]) -> list[str]: + """Return validation errors for a provider's top-level ``oauth`` block.""" + import oauth_bootstrap + + oauth = provider.get("oauth") or {} + if not oauth.get("type"): + return [] + errors: list[str] = [] + otype = (oauth.get("type") or "").strip() + if otype not in oauth_bootstrap.SUPPORTED_TYPES: + errors.append( + f"oauth.type must be one of {sorted(oauth_bootstrap.SUPPORTED_TYPES)}" + ) + return errors + for key in ("client_secret_file", "token_file"): + if not (oauth.get(key) or "").strip(): + errors.append(f"oauth.{key} is required for {otype} oauth") + scopes = oauth.get("scopes") + if not isinstance(scopes, list) or not [s for s in (scopes or []) if s]: + errors.append("oauth.scopes must be a non-empty list") + secret_file = (oauth.get("client_secret_file") or "").strip() + if secret_file and not Path(secret_file).is_file(): + errors.append( + f"oauth.client_secret_file not found: {secret_file} β€” upload it via " + "the Files manager (e.g. tools/secrets/)" + ) + return errors + + def _validate_provider(provider: dict[str, Any]) -> dict[str, Any]: errors: list[str] = [] ptype = provider.get("type", "code") + errors.extend(_validate_oauth(provider)) if ptype == "rest": errors.extend(_validate_rest(provider)) @@ -631,6 +677,12 @@ async def list_tools() -> list[dict]: is_package = bool(_get_package_spec(spec)) is_repository = bool(_get_repository_spec(spec)) is_rest = bool(_get_rest_spec(spec)) + oauth_cfg = spec.get("oauth") or {} + oauth_out = None + if oauth_cfg.get("type"): + import oauth_bootstrap + oauth_out = {"type": oauth_cfg.get("type"), + **oauth_bootstrap.token_status(oauth_cfg)} out.append({ "name": path.stem, "file": path.name, @@ -644,6 +696,7 @@ async def list_tools() -> list[dict]: "missing_secrets": missing_secrets, "validation_errors": validation["errors"], "documentation": spec.get("documentation") or "", + "oauth": oauth_out, }) except Exception as exc: out.append({"name": path.stem, "file": path.name, "error": str(exc)}) @@ -836,6 +889,44 @@ async def rest_authorize(request: Request) -> dict: traceback.print_exc() return {"ok": False, "error": str(exc)} + @app.post("/api/oauth-bootstrap") + async def oauth_bootstrap_endpoint(request: Request) -> dict: + """Begin (or restart) the consent flow declared by a provider's + top-level ``oauth:`` block (e.g. a Google token-file bootstrap). + + Body: { name: }. Builds the consent URL (offline access + + PKCE), publishes it to the pending-auth banner, and returns + ``{ok, auth_url, redirect_uri, token}`` for the UI to open. + """ + body = await request.json() + name = (body.get("name") or "").strip() + _guard_name(name) + path = _config_dir / f"{name}.yaml" + if not path.exists(): + raise HTTPException(404, f"Provider '{name}' not found") + spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + import oauth_bootstrap + oauth_cfg = oauth_bootstrap.get_oauth_config(spec) + if oauth_cfg is None: + raise HTTPException(400, "Provider has no oauth block") + if (oauth_cfg.get("type") or "").strip() not in oauth_bootstrap.SUPPORTED_TYPES: + raise HTTPException( + 400, + f"Unsupported oauth.type (supported: {sorted(oauth_bootstrap.SUPPORTED_TYPES)})", + ) + try: + from rest_provider import oauth_redirect_uri + auth_url = oauth_bootstrap.begin_authorization(name, oauth_cfg) + return { + "ok": True, + "auth_url": auth_url, + "redirect_uri": oauth_redirect_uri(), + "token": oauth_bootstrap.token_status(oauth_cfg), + } + except Exception as exc: + traceback.print_exc() + return {"ok": False, "error": str(exc)} + @app.get("/oauth/callback") async def oauth_callback( code: str = "", state: str = "", error: str = "" @@ -1637,6 +1728,24 @@ async def index():
+ + +