odysseus-dev/odysseus · error · HTTPException

Invalid OAuth {field_name}: path must stay under {base}

Error message

Invalid OAuth {field_name}: path must stay under {base}

What it means

Raised when an OAuth file path (keys_file / token_file) supplied in an MCP server's oauth_file or oauth_config resolves outside the mcp_oauth base directory. The code expands ~, joins relative paths onto the base, resolves symlinks, then calls Path.relative_to(base); on ValueError it rejects the path with 400. This is a path-traversal containment guard for the OAuth credential store.

Source

Thrown at routes/mcp/mcp_routes.py:44

    return Path(MCP_OAUTH_DIR).resolve(strict=False)


def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
    """Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
    raw = str(raw_path or "").strip()
    if not raw:
        return ""

    base = _mcp_oauth_base_dir()
    path = Path(os.path.expanduser(raw))
    if not path.is_absolute():
        path = base / path
    resolved = path.resolve(strict=False)

    try:
        resolved.relative_to(base)
    except ValueError as exc:
        raise HTTPException(
            400,
            f"Invalid OAuth {field_name}: path must stay under {base}",
        ) from exc
    return str(resolved)


def _sanitize_mcp_oauth_config(oauth_cfg):
    """Return an OAuth config copy with file paths confined to mcp_oauth."""
    if not oauth_cfg:
        return oauth_cfg
    if not isinstance(oauth_cfg, dict):
        return {}
    sanitized = dict(oauth_cfg)
    for field_name in ("keys_file", "token_file"):
        if sanitized.get(field_name):
            sanitized[field_name] = _resolve_mcp_oauth_path(
                sanitized[field_name],
                field_name,

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Place the keys/token files inside the directory returned by _mcp_oauth_base_dir() and reference them by bare filename or path relative to it.
  2. If the file lives elsewhere, copy it into the mcp_oauth base dir and update the config to the relative name.
  3. Verify with Path(p).resolve().relative_to(base) locally before submitting the config.

Example fix

# before
oauth_file = "/home/me/creds/client_secret.json"

# after
import shutil, pathlib
base = pathlib.Path(_mcp_oauth_base_dir())
shutil.copy("/home/me/creds/client_secret.json", base / "client_secret.json")
oauth_file = "client_secret.json"  # relative to base
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path
def oauth_path_ok(raw: str, base: Path) -> bool:
    p = Path(raw).expanduser()
    if not p.is_absolute():
        p = base / p
    try:
        p.resolve(strict=False).relative_to(base.resolve(strict=False))
        return True
    except ValueError:
        return False

Try / catch

On 400 from server registration, check the detail for 'path must stay under' and fix the path rather than retrying.

Prevention

When it happens

Trigger: POSTing/PATCHing an MCP server with oauth_file="/etc/google/keys.json" (absolute path outside base), a relative path with traversal like "../../secrets/keys.json", or a symlink inside the base that resolves elsewhere.

Common situations: Reusing an absolute path from a different machine or setup guide; pointing at a keys file in the project root or home directory instead of the managed mcp_oauth directory; symlinked dotfiles whose realpath escapes the base.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/9728161a9d8e75f2. Report an issue: GitHub.