Skip to content

Share OAuthClientProvider across URL strategies#267

Draft
iamcristi wants to merge 16 commits intomainfrom
feat/oauth-support
Draft

Share OAuthClientProvider across URL strategies#267
iamcristi wants to merge 16 commits intomainfrom
feat/oauth-support

Conversation

@iamcristi
Copy link
Copy Markdown
Contributor

@iamcristi iamcristi commented Apr 13, 2026

Summary

  • Build a single shared OAuthClientProvider once in check_server() keyed on the original URL, then forward it through _check_server_pass() and get_client() so DCR runs at most once across all strategy attempts (SSE/HTTP URL mutations).
  • Add top-level mcpServers support in ClaudeCodeConfigFile so global servers outside per-project blocks are parsed correctly.

@iamcristi iamcristi requested a review from a team as a code owner April 13, 2026 09:53
@qodo-merge-etso
Copy link
Copy Markdown

Review Summary by Qodo

Add interactive OAuth support for remote MCP servers

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add --enable-oauth CLI flag for interactive OAuth authentication
• Implement browser-based OAuth flow with local callback server on port 3030
• Add InteractiveTokenStorage for persisting tokens per-server in ~/.mcp-scan-oauth/
• Fix FileTokenStorage.set_tokens() and set_client_info() stubs
• Wire OAuth through SSE and HTTP transport paths
• Add 26 unit tests covering OAuth, token storage, and CLI integration
Diagram
flowchart LR
  CLI["CLI --enable-oauth flag"]
  CLI --> InspectArgs["InspectArgs.enable_oauth"]
  InspectArgs --> InspectClient["inspect_client"]
  InspectClient --> InspectExt["inspect_extension"]
  InspectExt --> CheckServer["check_server"]
  CheckServer --> GetClient["get_client"]
  GetClient --> OAuthProvider["OAuthClientProvider"]
  OAuthProvider --> Storage["InteractiveTokenStorage"]
  Storage --> Callback["OAuth callback server"]
  Callback --> Browser["Browser authorization"]
Loading

Grey Divider

File Changes

1. src/agent_scan/cli.py ✨ Enhancement +9/-0

Add --enable-oauth CLI flag

src/agent_scan/cli.py


2. src/agent_scan/inspect.py ✨ Enhancement +9/-3

Thread enable_oauth through inspection pipeline

src/agent_scan/inspect.py


3. src/agent_scan/mcp_client.py ✨ Enhancement +53/-32

Centralize OAuth provider construction and wire through transports

src/agent_scan/mcp_client.py


View more (7)
4. src/agent_scan/models.py ✨ Enhancement +63/-2

Implement InteractiveTokenStorage and fix FileTokenStorage stubs

src/agent_scan/models.py


5. src/agent_scan/oauth.py ✨ Enhancement +182/-0

New OAuth module with browser-based callback server

src/agent_scan/oauth.py


6. src/agent_scan/pipelines.py ✨ Enhancement +6/-1

Add enable_oauth to InspectArgs and pass through pipeline

src/agent_scan/pipelines.py


7. tests/unit/test_cli_parsing.py 🧪 Tests +50/-1

Add tests for --enable-oauth flag parsing

tests/unit/test_cli_parsing.py


8. tests/unit/test_mcp_client.py 🧪 Tests +120/-2

Add OAuth integration tests for get_client and check_server

tests/unit/test_mcp_client.py


9. tests/unit/test_models.py 🧪 Tests +137/-1

Add tests for InteractiveTokenStorage and FileTokenStorage fixes

tests/unit/test_models.py


10. tests/unit/test_oauth.py 🧪 Tests +140/-0

New comprehensive OAuth module unit tests

tests/unit/test_oauth.py


Grey Divider

Qodo Logo

@qodo-merge-etso
Copy link
Copy Markdown

qodo-merge-etso bot commented Apr 13, 2026

Code Review by Qodo

🐞 Bugs (2)   📘 Rule violations (2)   📎 Requirement gaps (0)   🖥 UI issues (0)   🎨 UX Issues (0)
🐞\ ≡ Correctness (1) ☼ Reliability (1)
📘\ ☼ Reliability (1) ⛨ Security (1)

Grey Divider


Action required

1. Logs authorization_url value 📘
Description
open_browser_to_authorize() logs the full OAuth authorization_url, which can include sensitive
parameters (e.g., state) and should not be written to logs. This risks leaking secrets via log
collection or shared terminals.
Code

src/agent_scan/oauth.py[R21-28]

+async def open_browser_to_authorize(authorization_url: str) -> None:
+    """Open the authorization URL in the user's default browser.
+
+    Args:
+        authorization_url: The OAuth authorization URL to open.
+    """
+    logger.info("Opening browser for OAuth authorization: %s", authorization_url)
+    webbrowser.open(authorization_url)
Evidence
Compliance rule forbids logging secrets; the new code logs the full authorization URL via
logger.info(...), which may embed sensitive OAuth parameters.

Rule 7: never log secrets/PII
src/agent_scan/oauth.py[21-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`open_browser_to_authorize()` logs the full OAuth authorization URL, which may contain sensitive query parameters.

## Issue Context
OAuth authorization URLs commonly include `state` and other parameters that should not be logged.

## Fix Focus Areas
- src/agent_scan/oauth.py[21-28]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. wait_for_oauth_callback() too long 📘
Description
wait_for_oauth_callback() exceeds the 40-line function length limit, making it harder to reason
about and test in isolation. Refactoring into smaller helpers would improve maintainability and
reduce future bug risk.
Code

src/agent_scan/oauth.py[R31-107]

+async def wait_for_oauth_callback(
+    host: str = "localhost",
+    port: int = 3030,
+    timeout: float = 300.0,
+) -> tuple[str, str | None]:
+    """Start a temporary HTTP server and wait for the OAuth callback.
+
+    Uses a threaded HTTP server so that synchronous callers (e.g.
+    ``urllib.request.urlopen``) do not block the async event loop.
+
+    Args:
+        host: The hostname to bind the callback server to.
+        port: The port to bind the callback server to.
+        timeout: Maximum seconds to wait for the callback before raising TimeoutError.
+
+    Returns:
+        A tuple of (code, state). state may be None if not provided.
+
+    Raises:
+        OAuthCallbackError: If the callback request is missing the ``code`` parameter.
+        TimeoutError: If no callback is received within the timeout period.
+    """
+    loop = asyncio.get_running_loop()
+    result_future: asyncio.Future[tuple[str, str | None]] = loop.create_future()
+
+    class _CallbackHandler(BaseHTTPRequestHandler):
+        def do_GET(self) -> None:  # noqa: N802
+            parsed = urlparse(self.path)
+            if parsed.path != "/callback":
+                self.send_response(404)
+                self.end_headers()
+                return
+
+            params = parse_qs(parsed.query)
+            code_list = params.get("code")
+            state_list = params.get("state")
+
+            code = code_list[0] if code_list else None
+            state = state_list[0] if state_list else None
+
+            if code is None:
+                self.send_response(400)
+                self.send_header("Content-Type", "text/plain")
+                self.end_headers()
+                self.wfile.write(b"Missing 'code' parameter")
+                if not result_future.done():
+                    loop.call_soon_threadsafe(
+                        result_future.set_exception,
+                        OAuthCallbackError("OAuth callback missing required 'code' parameter"),
+                    )
+                return
+
+            self.send_response(200)
+            self.send_header("Content-Type", "text/plain")
+            self.end_headers()
+            self.wfile.write(b"Authorization successful. You can close this window.")
+            if not result_future.done():
+                loop.call_soon_threadsafe(result_future.set_result, (code, state))
+
+        def log_message(self, format: str, *args: object) -> None:
+            """Suppress default stderr logging from BaseHTTPRequestHandler."""
+            pass
+
+    server = HTTPServer((host, port), _CallbackHandler)
+    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
+    server_thread.start()
+
+    try:
+        return await asyncio.wait_for(asyncio.shield(result_future), timeout=timeout)
+    except asyncio.TimeoutError:
+        raise TimeoutError(
+            f"OAuth callback was not received within {timeout} seconds"
+        )
+    finally:
+        server.shutdown()
+        server_thread.join(timeout=2)
+
Evidence
The compliance checklist limits functions to ≤40 SLOC; the newly added wait_for_oauth_callback()
spans far more than 40 lines in the diff.

Rule 45: Limit function length to ≤ 40 lines (SLOC)
src/agent_scan/oauth.py[31-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`wait_for_oauth_callback()` is a monolithic function well over 40 lines.

## Issue Context
The function currently defines an inner handler class, starts/stops a thread, and performs async waiting/timeout handling all in one block.

## Fix Focus Areas
- src/agent_scan/oauth.py[31-107]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. OAuth storage keyed by path 🐞
Description
InteractiveTokenStorage uses server_config.url as its persistence key, but check_server()
mutates server_config.url while probing /mcp and /sse variants. This causes tokens to be stored
under different directories for the same logical server, leading to repeated authorization prompts
and fragmented token state.
Code

src/agent_scan/mcp_client.py[R93-98]

+    elif enable_oauth and isinstance(server_config, RemoteServer):
+        storage = InteractiveTokenStorage(server_url=server_config.url)
+        oauth_client_provider = build_oauth_client_provider(
+            server_url=server_config.url,
+            storage=storage,
+        )
Evidence
get_client() constructs InteractiveTokenStorage(server_url=server_config.url), and
check_server() rewrites server_config.url inside its strategy loop; therefore the storage key is
unstable across protocol probing and across runs depending on which URL variant succeeds.

src/agent_scan/mcp_client.py[64-99]
src/agent_scan/mcp_client.py[236-276]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Interactive OAuth token persistence is keyed off `server_config.url`, but `check_server()` mutates that URL while trying different protocol/path combinations. This makes the token storage directory vary per attempt, so previously-authorized tokens often won’t be found.

### Issue Context
Users may be forced to re-authorize multiple times for the same server, and multiple per-variant token directories may be created.

### Fix Focus Areas
- Use a stable key for token storage (e.g., `scheme://host:port` from `urlparse(url)`), not the full mutable path.
- Alternatively, capture the original URL before probing and pass a separate stable `oauth_server_id`/`storage_key` into `get_client()`.

### Fix Focus Areas (code locations)
- src/agent_scan/mcp_client.py[80-98]
- src/agent_scan/mcp_client.py[236-276]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Insecure token file perms🐞
Description
InteractiveTokenStorage writes OAuth tokens and client info under ~/.mcp-scan-oauth without setting
restrictive directory/file permissions, so credentials can become readable by other local users
depending on umask. This can expose access/refresh tokens and enable account/server access by
unintended principals.
Code

src/agent_scan/models.py[R488-523]

+    async def get_tokens(self) -> OAuthToken | None:
+        """Read tokens from {storage_dir}/tokens.json, returning None if absent."""
+        token_path = self._get_storage_dir() / "tokens.json"
+        if not token_path.exists():
+            return None
+        with open(token_path, encoding="utf-8") as f:
+            data = json.load(f)
+        return OAuthToken.model_validate(data)
+
+    async def set_tokens(self, tokens: OAuthToken) -> None:
+        """Write tokens to {storage_dir}/tokens.json."""
+        token_path = self._get_storage_dir() / "tokens.json"
+        with open(token_path, "w", encoding="utf-8") as f:
+            json.dump(tokens.model_dump(mode="json"), f)
+
+    async def get_client_info(self) -> OAuthClientInformationFull | None:
+        """Read client info from {storage_dir}/client_info.json, returning None if absent."""
+        info_path = self._get_storage_dir() / "client_info.json"
+        if not info_path.exists():
+            return None
+        with open(info_path, encoding="utf-8") as f:
+            data = json.load(f)
+        return OAuthClientInformationFull.model_validate(data)
+
+    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
+        """Write client info to {storage_dir}/client_info.json."""
+        info_path = self._get_storage_dir() / "client_info.json"
+        with open(info_path, "w", encoding="utf-8") as f:
+            json.dump(client_info.model_dump(mode="json"), f)
+
+    def _get_storage_dir(self) -> Path:
+        """Return the per-server storage directory, creating it if necessary."""
+        safe_name = self._url_safe_filename(self._server_url)
+        storage_dir = Path(self._base_dir) / safe_name
+        os.makedirs(storage_dir, exist_ok=True)
+        return storage_dir
Evidence
InteractiveTokenStorage creates its storage directory with default permissions and writes JSON
token/client files without explicitly restricting mode, so resulting permissions are umask-dependent
and can be group/world-readable on common configurations.

src/agent_scan/models.py[477-523]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`InteractiveTokenStorage` persists OAuth credentials on disk but does not enforce secure permissions on the directory or the files. On systems with permissive umask, `tokens.json` / `client_info.json` may be readable by other local users.

### Issue Context
This is credential material (access/refresh tokens). Even “local-only” exposure is a security vulnerability on multi-user machines.

### Fix Focus Areas
- Ensure the per-server directory is created with `0o700`.
- Ensure written files are created/updated with `0o600` (consider `os.open(..., 0o600)` + `os.fdopen`, or `chmod` after atomic replace).
- Avoid following symlinks when writing sensitive files if feasible.

### Fix Focus Areas (code locations)
- src/agent_scan/models.py[484-523]
- src/agent_scan/models.py[497-516]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Callback socket not closed 🐞
Description
wait_for_oauth_callback() shuts down the HTTPServer thread but never calls server_close(), so
the listening socket may remain open longer than expected and can contribute to port reuse problems.
Additionally, binding errors (e.g. port already in use) aren’t handled to provide a clear actionable
error path.
Code

src/agent_scan/oauth.py[R94-106]

+    server = HTTPServer((host, port), _CallbackHandler)
+    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
+    server_thread.start()
+
+    try:
+        return await asyncio.wait_for(asyncio.shield(result_future), timeout=timeout)
+    except asyncio.TimeoutError:
+        raise TimeoutError(
+            f"OAuth callback was not received within {timeout} seconds"
+        )
+    finally:
+        server.shutdown()
+        server_thread.join(timeout=2)
Evidence
The callback server is created via HTTPServer((host, port), ...) and the finally block only
calls shutdown() and join(); it does not call server_close() to close the socket.

src/agent_scan/oauth.py[94-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The OAuth callback HTTP server is shut down but its socket isn’t explicitly closed, and bind failures aren’t surfaced as clear user-facing errors.

### Issue Context
This can lead to port reuse issues and confusing failures when port 3030 is already occupied.

### Fix Focus Areas
- Call `server.server_close()` in the `finally` block after `shutdown()`.
- Wrap server creation (`HTTPServer((host, port), ...)`) in a try/except for `OSError` and raise a clear message (e.g., “port 3030 in use; close the process or configure a different port”).
- Optionally support choosing an ephemeral port (`port=0`) and reflect the actual bound port in redirect URIs.

### Fix Focus Areas (code locations)
- src/agent_scan/oauth.py[31-107]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Hardcoded OAuth port/timeout📘
Description
OAuth callback port and timeouts are hardcoded (3030, 300.0, join timeout 2) rather than
defined as named constants. This reduces clarity and makes future changes error-prone since the same
values are used across modules.
Code

src/agent_scan/oauth.py[R31-35]

+async def wait_for_oauth_callback(
+    host: str = "localhost",
+    port: int = 3030,
+    timeout: float = 300.0,
+) -> tuple[str, str | None]:
Evidence
The checklist requires replacing magic numbers with named constants; the new OAuth flow introduces
several non-obvious numeric literals (port and timeouts) directly in function signatures/logic and
also repeats port usage in redirect URIs.

Rule 40: Replace magic numbers with named constants
src/agent_scan/oauth.py[31-35]
src/agent_scan/oauth.py[122-125]
src/agent_scan/oauth.py[104-107]
src/agent_scan/oauth.py[157-162]
src/agent_scan/mcp_client.py[83-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OAuth port/timeouts are hardcoded numeric literals instead of named constants.

## Issue Context
The callback port (`3030`) and timeouts (`300.0`, thread join timeout `2`) are domain values that should be centralized to avoid duplication and improve readability.

## Fix Focus Areas
- src/agent_scan/oauth.py[31-35]
- src/agent_scan/oauth.py[104-107]
- src/agent_scan/oauth.py[122-125]
- src/agent_scan/oauth.py[157-162]
- src/agent_scan/mcp_client.py[83-88]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Blocking non-atomic token IO🐞
Description
InteractiveTokenStorage performs synchronous reads/writes and direct overwrites inside async
methods, which can block the event loop and risks partial/corrupted JSON if interrupted mid-write.
This can cause authentication failures on subsequent runs if token files become unreadable.
Code

src/agent_scan/models.py[R488-516]

+    async def get_tokens(self) -> OAuthToken | None:
+        """Read tokens from {storage_dir}/tokens.json, returning None if absent."""
+        token_path = self._get_storage_dir() / "tokens.json"
+        if not token_path.exists():
+            return None
+        with open(token_path, encoding="utf-8") as f:
+            data = json.load(f)
+        return OAuthToken.model_validate(data)
+
+    async def set_tokens(self, tokens: OAuthToken) -> None:
+        """Write tokens to {storage_dir}/tokens.json."""
+        token_path = self._get_storage_dir() / "tokens.json"
+        with open(token_path, "w", encoding="utf-8") as f:
+            json.dump(tokens.model_dump(mode="json"), f)
+
+    async def get_client_info(self) -> OAuthClientInformationFull | None:
+        """Read client info from {storage_dir}/client_info.json, returning None if absent."""
+        info_path = self._get_storage_dir() / "client_info.json"
+        if not info_path.exists():
+            return None
+        with open(info_path, encoding="utf-8") as f:
+            data = json.load(f)
+        return OAuthClientInformationFull.model_validate(data)
+
+    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
+        """Write client info to {storage_dir}/client_info.json."""
+        info_path = self._get_storage_dir() / "client_info.json"
+        with open(info_path, "w", encoding="utf-8") as f:
+            json.dump(client_info.model_dump(mode="json"), f)
Evidence
get_tokens/set_tokens/get_client_info/set_client_info use open() + json.load/dump
directly within async def methods and write to the final path rather than atomically replacing, so
operations are blocking and not crash-safe.

src/agent_scan/models.py[488-516]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`InteractiveTokenStorage` does blocking file I/O inside async methods and writes directly to `tokens.json`/`client_info.json`. This can block the event loop and can leave corrupted files if the process is killed during write.

### Issue Context
OAuth token persistence should be robust because a corrupted token file can break future authentication.

### Fix Focus Areas
- Move JSON read/write into `asyncio.to_thread(...)` (or adopt an async file library).
- Use atomic write: write to a temp file in the same directory and `os.replace()` into place.
- Combine with secure permissions from the security fix.

### Fix Focus Areas (code locations)
- src/agent_scan/models.py[488-516]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
8. Unexpected interactive browser fallback🐞
Description
When a token is provided, get_client() still wires a browser-opening redirect handler and a
localhost callback handler, so a token-auth run can become interactive and block waiting for a
callback even if --enable-oauth is not set. This can hang or fail in headless/CI environments if
the provider needs to re-authorize.
Code

src/agent_scan/mcp_client.py[R80-92]

+    if token and isinstance(server_config, RemoteServer):
+        oauth_client_provider = OAuthClientProvider(
+            server_url=token.mcp_server_url,
+            client_metadata=OAuthClientMetadata(
+                client_name="mcp-scan",
+                grant_types=["authorization_code", "refresh_token"],
+                response_types=["code"],
+                redirect_uris=["http://localhost:3030/callback"],
+            ),
+            storage=FileTokenStorage(data=token),
+            redirect_handler=make_redirect_handler(),
+            callback_handler=make_callback_handler(),
+        )
Evidence
The token-based OAuthClientProvider path always uses make_redirect_handler() (opens a browser) and
make_callback_handler() (starts a local callback server and waits up to 300s). These handlers are
installed regardless of the enable_oauth flag.

src/agent_scan/mcp_client.py[80-92]
src/agent_scan/oauth.py[21-29]
src/agent_scan/oauth.py[122-139]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Token-based auth currently installs interactive handlers (open browser + wait for localhost callback). If re-authorization is required, a non-interactive run may block/hang even though `--enable-oauth` is false.

### Issue Context
The CLI adds `--enable-oauth` as an opt-in for interactive auth; token-driven scans should not silently become interactive unless explicitly allowed.

### Fix Focus Areas
- In the `token` branch, only use `make_redirect_handler()`/`make_callback_handler()` when `enable_oauth=True`.
- When `enable_oauth=False`, provide non-interactive handlers that fail fast with a clear error instructing the user to rerun with `--enable-oauth`.

### Fix Focus Areas (code locations)
- src/agent_scan/mcp_client.py[80-92]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. OAuth provider construction duplicated📘
Description
OAuth provider construction (metadata + handlers) is duplicated in get_client() instead of reusing
the new build_oauth_client_provider() helper. This increases the chance of future divergence
(e.g., redirect URIs, grant types) across auth paths.
Code

src/agent_scan/mcp_client.py[R78-98]

+    # Construct the OAuthClientProvider centrally
+    oauth_client_provider: OAuthClientProvider | None = None
+    if token and isinstance(server_config, RemoteServer):
+        oauth_client_provider = OAuthClientProvider(
+            server_url=token.mcp_server_url,
+            client_metadata=OAuthClientMetadata(
+                client_name="mcp-scan",
+                grant_types=["authorization_code", "refresh_token"],
+                response_types=["code"],
+                redirect_uris=["http://localhost:3030/callback"],
+            ),
+            storage=FileTokenStorage(data=token),
+            redirect_handler=make_redirect_handler(),
+            callback_handler=make_callback_handler(),
+        )
+    elif enable_oauth and isinstance(server_config, RemoteServer):
+        storage = InteractiveTokenStorage(server_url=server_config.url)
+        oauth_client_provider = build_oauth_client_provider(
+            server_url=server_config.url,
+            storage=storage,
+        )
Evidence
The DRY rule requires reuse of shared utilities; build_oauth_client_provider() is introduced for
provider construction, but get_client() still manually builds an OAuthClientProvider with the
same metadata/handlers for the token case.

Rule 5: Avoid duplication (DRY)—reuse utilities instead of copy-pasting
src/agent_scan/mcp_client.py[78-98]
src/agent_scan/oauth.py[142-170]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Provider construction logic is duplicated between `agent_scan.mcp_client.get_client()` and `agent_scan.oauth.build_oauth_client_provider()`.

## Issue Context
Both code paths define the same `OAuthClientMetadata` fields and use the same redirect/callback handler factories; keeping them duplicated risks inconsistent behavior.

## Fix Focus Areas
- src/agent_scan/mcp_client.py[78-98]
- src/agent_scan/oauth.py[142-170]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@qodo-merge-etso
Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: pre-commit

Failed stage: Run pre-commit/action@v3.0.1 [❌]

Failed test name: ""

Failure summary:

The GitHub Action failed during the pre-commit run --show-diff-on-failure --color=always --all-files
step because the ruff and ruff-format hooks did not pass (exit code 1).
- ruff reported remaining
lint violations:
- src/agent_scan/oauth.py:101:9 B904: inside an except asyncio.TimeoutError:
block, the code raises TimeoutError(...) without exception chaining (raise ... from err or raise ...
from None).
- tests/unit/test_oauth.py:81:13 SIM105: suggests replacing try/except Exception/pass
with contextlib.suppress(Exception).
- ruff/ruff-format also modified files automatically (the log
shows diffs in src/agent_scan/oauth.py and tests/unit/test_oauth.py), which causes the pre-commit
run to fail unless those changes are already committed.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

581:  ##[endgroup]
582:  Cache hit for: pre-commit-3|/opt/hostedtoolcache/Python/3.14.3/x64|e4d30abbaa89ff6a158c7bfd3035f96712ec4fc3e376e42c5b3373de004c94b1
583:  Received 25134291 of 25134291 (100.0%), 42.0 MBs/sec
584:  Cache Size: ~24 MB (25134291 B)
585:  [command]/usr/bin/tar -xf /home/runner/work/_temp/411d8303-dbbc-4591-8341-ec6bf102d30e/cache.tzst -P -C /home/runner/work/agent-scan/agent-scan --use-compress-program unzstd
586:  Cache restored successfully
587:  Cache restored from key: pre-commit-3|/opt/hostedtoolcache/Python/3.14.3/x64|e4d30abbaa89ff6a158c7bfd3035f96712ec4fc3e376e42c5b3373de004c94b1
588:  ##[group]Run pre-commit run --show-diff-on-failure --color=always --all-files
589:  �[36;1mpre-commit run --show-diff-on-failure --color=always --all-files�[0m
590:  shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
591:  env:
592:  pythonLocation: /opt/hostedtoolcache/Python/3.14.3/x64
593:  LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.14.3/x64/lib
594:  ##[endgroup]
595:  mypy.....................................................................�[42mPassed�[m
596:  ruff.....................................................................�[41mFailed�[m
597:  �[2m- hook id: ruff�[m
598:  �[2m- exit code: 1�[m
599:  �[2m- files were modified by this hook�[m
600:  �[1msrc/agent_scan/oauth.py�[0m�[36m:�[0m101�[36m:�[0m9�[36m:�[0m �[1;31mB904�[0m Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling
601:  �[1m�[94m|�[0m
602:  �[1m�[94m 99 |�[0m           return await asyncio.wait_for(asyncio.shield(result_future), timeout=timeout)
603:  �[1m�[94m100 |�[0m       except asyncio.TimeoutError:
604:  �[1m�[94m101 |�[0m �[1m�[91m/�[0m         raise TimeoutError(
605:  �[1m�[94m102 |�[0m �[1m�[91m|�[0m             f"OAuth callback was not received within {timeout} seconds"
606:  �[1m�[94m103 |�[0m �[1m�[91m|�[0m         )
607:  �[1m�[94m|�[0m �[1m�[91m|_________^�[0m �[1m�[91mB904�[0m
608:  �[1m�[94m104 |�[0m       finally:
609:  �[1m�[94m105 |�[0m           server.shutdown()
610:  �[1m�[94m|�[0m
611:  �[1mtests/unit/test_oauth.py�[0m�[36m:�[0m81�[36m:�[0m13�[36m:�[0m �[1;31mSIM105�[0m Use `contextlib.suppress(Exception)` instead of `try`-`except`-`pass`
612:  �[1m�[94m|�[0m
613:  �[1m�[94m79 |�[0m               await asyncio.sleep(0.3)
614:  �[1m�[94m80 |�[0m               url = f"http://localhost:{port}/callback?state=test_state"
615:  �[1m�[94m81 |�[0m �[1m�[91m/�[0m             try:
616:  �[1m�[94m82 |�[0m �[1m�[91m|�[0m                 urllib.request.urlopen(url, timeout=5)
617:  �[1m�[94m83 |�[0m �[1m�[91m|�[0m             except Exception:
618:  �[1m�[94m84 |�[0m �[1m�[91m|�[0m                 pass  # Server may respond with an error status
619:  �[1m�[94m|�[0m �[1m�[91m|____________________^�[0m �[1m�[91mSIM105�[0m
620:  �[1m�[94m85 |�[0m
621:  �[1m�[94m86 |�[0m           task = asyncio.create_task(send_callback())
622:  �[1m�[94m|�[0m
623:  �[1m�[94m= �[0m�[1m�[96mhelp�[0m: Replace with `contextlib.suppress(Exception)`
624:  Found 4 errors (2 fixed, 2 remaining).
625:  ruff-format..............................................................�[41mFailed�[m
626:  �[2m- hook id: ruff-format�[m
...

640:  �[1mindex 1a4bd2a..2b486c9 100644�[m
641:  �[1m--- a/src/agent_scan/oauth.py�[m
642:  �[1m+++ b/src/agent_scan/oauth.py�[m
643:  �[36m@@ -54,7 +54,7 @@�[m �[masync def wait_for_oauth_callback(�[m
644:  result_future: asyncio.Future[tuple[str, str | None]] = loop.create_future()�[m
645:  �[m
646:  class _CallbackHandler(BaseHTTPRequestHandler):�[m
647:  �[31m-        def do_GET(self) -> None:  # noqa: N802�[m
648:  �[32m+�[m�[32m        def do_GET(self) -> None:�[m
649:  parsed = urlparse(self.path)�[m
650:  if parsed.path != "/callback":�[m
651:  self.send_response(404)�[m
652:  �[36m@@ -98,9 +98,7 @@�[m �[masync def wait_for_oauth_callback(�[m
653:  try:�[m
654:  return await asyncio.wait_for(asyncio.shield(result_future), timeout=timeout)�[m
655:  except asyncio.TimeoutError:�[m
656:  �[31m-        raise TimeoutError(�[m
657:  �[31m-            f"OAuth callback was not received within {timeout} seconds"�[m
658:  �[31m-        )�[m
659:  �[32m+�[m�[32m        raise TimeoutError(f"OAuth callback was not received within {timeout} seconds")�[m
660:  finally:�[m
661:  server.shutdown()�[m
662:  server_thread.join(timeout=2)�[m
663:  �[1mdiff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py�[m
664:  �[1mindex 8a66090..8f53faa 100644�[m
665:  �[1m--- a/tests/unit/test_oauth.py�[m
666:  �[1m+++ b/tests/unit/test_oauth.py�[m
667:  �[36m@@ -1,7 +1,6 @@�[m
668:  """Unit tests for the OAuth module (agent_scan.oauth)."""�[m
669:  �[m
670:  import asyncio�[m
671:  �[31m-import threading�[m
672:  from unittest.mock import patch�[m
673:  �[m
674:  import pytest�[m
675:  ##[error]Process completed with exit code 1.
676:  Post job cleanup.

Comment on lines +21 to +28
async def open_browser_to_authorize(authorization_url: str) -> None:
"""Open the authorization URL in the user's default browser.

Args:
authorization_url: The OAuth authorization URL to open.
"""
logger.info("Opening browser for OAuth authorization: %s", authorization_url)
webbrowser.open(authorization_url)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Logs authorization_url value 📘 Rule violation ⛨ Security

open_browser_to_authorize() logs the full OAuth authorization_url, which can include sensitive
parameters (e.g., state) and should not be written to logs. This risks leaking secrets via log
collection or shared terminals.
Agent Prompt
## Issue description
`open_browser_to_authorize()` logs the full OAuth authorization URL, which may contain sensitive query parameters.

## Issue Context
OAuth authorization URLs commonly include `state` and other parameters that should not be logged.

## Fix Focus Areas
- src/agent_scan/oauth.py[21-28]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +31 to +107
async def wait_for_oauth_callback(
host: str = "localhost",
port: int = 3030,
timeout: float = 300.0,
) -> tuple[str, str | None]:
"""Start a temporary HTTP server and wait for the OAuth callback.

Uses a threaded HTTP server so that synchronous callers (e.g.
``urllib.request.urlopen``) do not block the async event loop.

Args:
host: The hostname to bind the callback server to.
port: The port to bind the callback server to.
timeout: Maximum seconds to wait for the callback before raising TimeoutError.

Returns:
A tuple of (code, state). state may be None if not provided.

Raises:
OAuthCallbackError: If the callback request is missing the ``code`` parameter.
TimeoutError: If no callback is received within the timeout period.
"""
loop = asyncio.get_running_loop()
result_future: asyncio.Future[tuple[str, str | None]] = loop.create_future()

class _CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != "/callback":
self.send_response(404)
self.end_headers()
return

params = parse_qs(parsed.query)
code_list = params.get("code")
state_list = params.get("state")

code = code_list[0] if code_list else None
state = state_list[0] if state_list else None

if code is None:
self.send_response(400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Missing 'code' parameter")
if not result_future.done():
loop.call_soon_threadsafe(
result_future.set_exception,
OAuthCallbackError("OAuth callback missing required 'code' parameter"),
)
return

self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Authorization successful. You can close this window.")
if not result_future.done():
loop.call_soon_threadsafe(result_future.set_result, (code, state))

def log_message(self, format: str, *args: object) -> None:
"""Suppress default stderr logging from BaseHTTPRequestHandler."""
pass

server = HTTPServer((host, port), _CallbackHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()

try:
return await asyncio.wait_for(asyncio.shield(result_future), timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError(
f"OAuth callback was not received within {timeout} seconds"
)
finally:
server.shutdown()
server_thread.join(timeout=2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. wait_for_oauth_callback() too long 📘 Rule violation ☼ Reliability

wait_for_oauth_callback() exceeds the 40-line function length limit, making it harder to reason
about and test in isolation. Refactoring into smaller helpers would improve maintainability and
reduce future bug risk.
Agent Prompt
## Issue description
`wait_for_oauth_callback()` is a monolithic function well over 40 lines.

## Issue Context
The function currently defines an inner handler class, starts/stops a thread, and performs async waiting/timeout handling all in one block.

## Fix Focus Areas
- src/agent_scan/oauth.py[31-107]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +93 to +98
elif enable_oauth and isinstance(server_config, RemoteServer):
storage = InteractiveTokenStorage(server_url=server_config.url)
oauth_client_provider = build_oauth_client_provider(
server_url=server_config.url,
storage=storage,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Oauth storage keyed by path 🐞 Bug ≡ Correctness

InteractiveTokenStorage uses server_config.url as its persistence key, but check_server()
mutates server_config.url while probing /mcp and /sse variants. This causes tokens to be stored
under different directories for the same logical server, leading to repeated authorization prompts
and fragmented token state.
Agent Prompt
### Issue description
Interactive OAuth token persistence is keyed off `server_config.url`, but `check_server()` mutates that URL while trying different protocol/path combinations. This makes the token storage directory vary per attempt, so previously-authorized tokens often won’t be found.

### Issue Context
Users may be forced to re-authorize multiple times for the same server, and multiple per-variant token directories may be created.

### Fix Focus Areas
- Use a stable key for token storage (e.g., `scheme://host:port` from `urlparse(url)`), not the full mutable path.
- Alternatively, capture the original URL before probing and pass a separate stable `oauth_server_id`/`storage_key` into `get_client()`.

### Fix Focus Areas (code locations)
- src/agent_scan/mcp_client.py[80-98]
- src/agent_scan/mcp_client.py[236-276]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@iamcristi iamcristi marked this pull request as draft April 13, 2026 12:08
@iamcristi iamcristi changed the title Add interactive OAuth for remote MCP servers Share OAuthClientProvider across URL strategies Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant