Skip to content

Feat/oauth resolution - #452

Open
gitaleksey wants to merge 13 commits into
mainfrom
feat/oauth-resolution
Open

Feat/oauth resolution#452
gitaleksey wants to merge 13 commits into
mainfrom
feat/oauth-resolution

Conversation

@gitaleksey

Copy link
Copy Markdown
Collaborator

Title: Native OAuth support for remote MCP servers, single-server targeting, and credential hardening

Summary

Adds native OAuth credential handling so unattended scans can authenticate to OAuth-protected remote MCP servers, replacing the manual mcp-remote + hand-assembled-token-file workaround. Includes a new mcp-auth command for one-time interactive authorization (browser + loopback flow), a persistent token store at ~/.mcp-scan/oauth-tokens.json with proactive refresh, and bearer-token redaction on uploaded server output.
Adds --server, --url, and --server-type to scan/inspect for targeting exactly one MCP server (skipping every other server and all skills), plus prefixed positionals (streamable-https:, npm:, pypi:, oci:, …) for scanning a server directly from a URL or package with no config file needed.
Hardens the OAuth token store: token files are now created 0600 before writing (previously 0644 during the write window), the debug helper no longer prints access/refresh tokens or client secrets, the token exchange no longer follows redirects (avoids httpx re-sending the request body on 307/308), and refresh tokens are only sent to HTTPS or loopback endpoints.
Test plan

mcp-auth completes the browser/loopback flow and persists a usable token
scan --server NAME / scan --url URL --server-type http target a single server and skip skills
scan streamable-https:host/path resolves without a config file
Unattended scan picks up and refreshes a previously stored OAuth token
Token file permissions are 0600 immediately on creation
Debug helper output contains no tokens/secrets
uv run pytest passes (new coverage in test_oauth_store.py, test_debug_mcp_auth.py, test_single_server_scan.py, test_mcp_client.py, test_cli_parsing.py)

@gitaleksey
gitaleksey requested a review from a team as a code owner August 25, 2026 22:27
@gitguardian

gitguardian Bot commented Aug 25, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36116019 Triggered Bearer Token 81047b5 tests/unit/test_redact.py View secret
🛠 Guidelines to remediate hardcoded secrets

A potential secret has been detected in this pull request. Please reference the following documentation for next steps:

  1. How to Triage a Finding
  2. How to Handle a False Positive
  3. Secret Leakage Runbook - use only when the finding is a confirmed true positive.

Click on the hyperlinked GitGuardian ID in the table above for more information on the detection. Team leaders can review ignore approval requests via the GitGuardian ID as well.

If you have any questions, please contact your ProdSec partner.

Not your repo? Get access to the GitGuardian issue by following these steps.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@qodo-merge-etso

Copy link
Copy Markdown

PR Summary by Qodo

Add native MCP OAuth and single-server scan targeting

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds interactive MCP OAuth authorization with persistent, proactively refreshed credentials.
• Enables exact single-server targeting by name, URL, transport, or package prefix.
• Hardens token storage, refresh exchanges, diagnostics, and uploaded error redaction.
Diagram

sequenceDiagram
    actor User
    participant CLI as Agent Scan CLI
    participant Browser
    participant Callback as Loopback Callback
    participant Store as OAuth Token Store
    participant Scan as Scan Pipeline
    participant MCP as Remote MCP
    participant API as Analysis API
    User->>CLI: Run mcp-auth
    CLI->>Callback: Start local listener
    CLI->>MCP: Discover OAuth metadata
    CLI->>Browser: Open authorization URL
    Browser->>Callback: Return code and state
    Callback-->>CLI: Supply authorization code
    CLI->>MCP: Exchange authorization code
    MCP-->>CLI: Return OAuth tokens
    CLI->>Store: Persist credentials securely
    User->>CLI: Scan one target
    CLI->>Scan: Build narrowed plan
    Scan->>Store: Load stored credentials
    opt Token expired
        Scan->>MCP: Refresh without redirects
        MCP-->>Scan: Return rotated token
        Scan->>Store: Persist refreshed token
    end
    Scan->>MCP: Inspect with bearer token
    MCP-->>Scan: Return server results
    Scan->>API: Upload redacted output
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. OS-native credential vaults
  • ➕ Avoids storing raw OAuth secrets in a JSON file
  • ➕ Uses platform-managed encryption and access controls
  • ➖ Requires separate macOS, Windows, and Linux backends
  • ➖ Complicates headless and cross-platform deployments
  • ➖ Adds migration and dependency overhead
2. External OAuth sidecar
  • ➕ Centralizes authorization and refresh outside the scanner
  • ➕ Could support enterprise credential brokering
  • ➖ Adds another deployed service and trust boundary
  • ➖ Makes local and standalone scans harder to operate
  • ➖ Increases configuration and failure modes

Recommendation: The local normalized token store with a separate interactive mcp-auth command is the best near-term approach: it preserves unattended scan behavior, reuses the MCP SDK, and keeps credentials off the analysis platform while applying strict file and refresh safeguards. Keep the storage interface sufficiently isolated for a future OS-keystore backend, but avoid a sidecar until centralized credential brokering is an explicit deployment requirement.

Files changed (19) +3096 / -46

Enhancement (7) +1243 / -38
cli.pyAdd OAuth authorization and single-server CLI routing +177/-2

Add OAuth authorization and single-server CLI routing

• Introduces shared target arguments, the interactive 'mcp-auth' command, target validation, and not-found reporting. Routes URL targets directly and filters discovered plans for named targets while disabling skills and optionally pinning transport.

src/agent_scan/cli.py

debug_mcp_auth.pyAdd secret-safe OAuth diagnostics helper +80/-0

Add secret-safe OAuth diagnostics helper

• Adds a one-off interactive auth diagnostic command that reports stored credential metadata through a non-secret summary rather than printing tokens or client secrets.

src/agent_scan/debug_mcp_auth.py

inspect.pyThread strict transport behavior through inspection +9/-1

Thread strict transport behavior through inspection

• Propagates the transport-probing option through remote server inspection so pinned targets contact only the requested URL and transport.

src/agent_scan/inspect.py

mcp_client.pyUse persistent OAuth credentials across remote transports +82/-35

Use persistent OAuth credentials across remote transports

• Replaces file-only token use with normalized persistent OAuth storage and proactive refresh for HTTP and SSE connections. Adds strict one-attempt transport handling and surfaces the underlying error when probing is disabled.

src/agent_scan/mcp_client.py

oauth_flow.pyImplement interactive browser and loopback OAuth flow +297/-0

Implement interactive browser and loopback OAuth flow

• Adds MCP OAuth discovery, dynamic client registration, browser authorization, an ephemeral loopback callback listener, token persistence, and transport probing for the 'mcp-auth' command.

src/agent_scan/oauth_flow.py

oauth_store.pyAdd hardened persistent OAuth token storage +506/-0

Add hardened persistent OAuth token storage

• Adds normalized, locked, atomic token persistence with owner-only permissions and SDK storage integration. Proactively refreshes expired credentials while rejecting insecure token endpoints and redirects, preserving rotated refresh tokens, and exposing secret-safe diagnostics.

src/agent_scan/oauth_store.py

pipelines.pyBuild and filter single-server inspection plans +92/-0

Build and filter single-server inspection plans

• Adds helpers to create direct remote targets, retain only an exactly named discovered server, and enumerate discovered servers consistently. Carries strict transport probing behavior into inspection.

src/agent_scan/pipelines.py

Bug fix (2) +27 / -8
v20260710.pyRedact bearer tokens from API error payloads +2/-2

Redact bearer tokens from API error payloads

• Extends request error sanitization so bearer credentials cannot appear in uploaded messages, stack traces, or server output.

src/agent_scan/models/api/v20260710.py

redact.pyScrub OAuth bearer credentials from diagnostics +25/-6

Scrub OAuth bearer credentials from diagnostics

• Adds bearer-token recognition and applies it to traceback and captured server-output redaction as defense in depth.

src/agent_scan/redact.py

Tests (7) +938 / -0
test_cli_parsing.pyCover shared target argument parsing +61/-0

Cover shared target argument parsing

• Tests scan-style flags, 'mcp-auth' positionals, defaults, combined name and URL usage, and invalid transport choices.

tests/unit/test_cli_parsing.py

test_debug_mcp_auth.pyVerify OAuth diagnostics never print secrets +106/-0

Verify OAuth diagnostics never print secrets

• Covers existing-entry behavior and ensures realistic access tokens, refresh tokens, and client secrets never reach diagnostic output.

tests/unit/test_debug_mcp_auth.py

test_mcp_client.pyCover strict and probing transport strategies +63/-0

Cover strict and probing transport strategies

• Verifies default six-combination probing, exact one-attempt behavior for pinned transports, unchanged URLs, and direct exception propagation.

tests/unit/test_mcp_client.py

test_oauth_flow.pyTest OAuth transport, callback, and storage behavior +90/-0

Test OAuth transport, callback, and storage behavior

• Covers transport candidate generation, successful and failed loopback callbacks, loopback URI binding, and creation of persistent auth entries.

tests/unit/test_oauth_flow.py

test_oauth_store.pyComprehensively test token persistence and hardening +425/-0

Comprehensively test token persistence and hardening

• Covers URL normalization, persistence, refresh rotation, file permissions during writes, secret-safe summaries, redirect refusal, and HTTPS-or-loopback endpoint enforcement.

tests/unit/test_oauth_store.py

test_redact.pyTest bearer-token redaction +18/-0

Test bearer-token redaction

• Verifies common bearer formats and casing are scrubbed while unrelated and empty text remain unchanged.

tests/unit/test_redact.py

test_single_server_scan.pyTest exact server plan selection +175/-0

Test exact server plan selection

• Covers direct URL plans, name fallback, configured-server filtering, transport overrides, skill exclusion, stdio handling, discovery sentinels, and duplicate names.

tests/unit/test_single_server_scan.py

Documentation (3) +888 / -0
README.mdDocument exact single-server scan examples +11/-0

Document exact single-server scan examples

• Adds quick-start examples for targeting one configured server or one exact remote URL. Links to the expanded CLI reference for package and URL forms.

README.md

cli-reference.mdDocument server targeting and direct server inputs +44/-0

Document server targeting and direct server inputs

• Describes '--server', '--url', and '--server-type' semantics, validation, transport probing behavior, and skill exclusion. Documents prefixed URL and package positionals for config-free scans.

docs/cli-reference.md

2026-08-10-oauth-credential-hardening.mdRecord the OAuth hardening implementation plan +833/-0

Record the OAuth hardening implementation plan

• Provides the staged security remediation plan, test-first steps, verification commands, and deferred credential-management work.

docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md

@qodo-merge-etso

qodo-merge-etso Bot commented Aug 25, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (3) 📜 Skill insights (0)

Grey Divider


Action required

1. Duplicate names scan multiple servers ✓ Resolved 🐞 Bug ≡ Correctness
Description
filter_clients_to_server retains every exact-name match across all clients and config files, so
scan --server NAME can start or contact multiple distinct servers when names are duplicated. This
violates the new command's guarantee to target exactly one server and conflicts with the
first-occurrence-wins policy used by the companion discovery helper.
Code

src/agent_scan/pipelines.py[R273-280]

+            matches = [(entry_name, cfg) for entry_name, cfg in entries if entry_name == server_name]
+            if not matches:
+                continue
+            if server_type is not None:
+                for _entry_name, cfg in matches:
+                    if isinstance(cfg, RemoteServer):
+                        cfg.type = server_type
+            kept[config_path] = matches
Relevance

●●● Strong

Accepted deduplication precedents and the PR explicitly promises exactly one deterministic server
target.

PR-#327
PR-#321

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The filter collects all matches and appends every client containing one. The caller labels the
result as one server, while discover_servers_by_name uses setdefault to establish a
deterministic first-match policy for duplicate names.

src/agent_scan/pipelines.py[266-291]
src/agent_scan/pipelines.py[294-316]
src/agent_scan/cli.py[1318-1332]

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

## Issue description
Named single-server targeting retains all duplicate definitions instead of selecting one deterministic server.

## Issue Context
Use the same first-occurrence-wins behavior as `discover_servers_by_name`, while preserving the selected client's metadata and applying any transport override only to that entry.

## Fix Focus Areas
- src/agent_scan/pipelines.py[254-291]
- src/agent_scan/pipelines.py[294-316]
- src/agent_scan/cli.py[1318-1332]

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


2. Authentication failures exit successfully ✓ Resolved 🐞 Bug ≡ Correctness
Description
The mcp-auth dispatch unconditionally exits with status 0 after mcp_auth returns, while failed
AuthResult values and invalid or missing targets are only printed and returned. Scripts therefore
cannot detect that authentication failed or that no requested server was authenticated.
Code

src/agent_scan/cli.py[R1117-1119]

+    elif args.command == "mcp-auth":
+        asyncio.run(mcp_auth(args))
+        sys.exit(0)
Relevance

●●● Strong

CLI correctness failures are expected to propagate nonzero status; recent CLI validation precedents
favor explicit failure handling.

PR-#433
PR-#279

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Expected OAuth failures are converted into AuthResult(ok=False), but the CLI only prints that
result and falls through. Dispatch then explicitly exits zero regardless of the outcome.

src/agent_scan/oauth_flow.py[270-279]
src/agent_scan/cli.py[1213-1249]
src/agent_scan/cli.py[1117-1119]

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

## Issue description
`mcp-auth` always exits zero even when authentication or target selection fails.

## Issue Context
Have `mcp_auth` return a status and propagate it from dispatch; for multiple targets, return nonzero if any requested authentication fails, while still allowing all targets to be attempted.

## Fix Focus Areas
- src/agent_scan/cli.py[1117-1119]
- src/agent_scan/cli.py[1213-1249]

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


3. Mixed-case bearer tokens leak ✓ Resolved 🐞 Bug ⛨ Security
Description
The new bearer-token regex recognizes only Bearer and bearer, leaving case variants such as
BEARER or bEaReR unchanged. Such authorization values in captured server output or errors can
therefore survive the explicit credential-redaction boundary and be uploaded.
Code

src/agent_scan/redact.py[147]

+_BEARER_TOKEN_RE = re.compile(r"[Bb]earer\s+[\w.\-~+/]+=*")
Relevance

●●● Strong

Accepted security redaction precedent supports broadening token matching; mixed-case omission is a
direct deterministic leak.

PR-#365

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The regex's [Bb] only varies the first character. The repository applies this function to
diagnostic fields locally and again at API serialization, so an unmatched case variant remains in
uploadable error data.

src/agent_scan/redact.py[147-160]
src/agent_scan/redact.py[633-646]
src/agent_scan/redact.py[717-729]
src/agent_scan/models/api/v20260710.py[23-37]

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

## Issue description
Bearer credential redaction misses valid mixed-case authentication-scheme spellings.

## Issue Context
Compile the scheme match with case-insensitive behavior and add regression cases for uppercase and mixed-case forms across the error-redaction path.

## Fix Focus Areas
- src/agent_scan/redact.py[147-160]
- src/agent_scan/redact.py[633-646]
- tests/unit/test_redact.py[1269-1283]

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


View high (1)
4. SDK bypasses refresh guards 🐞 Bug ⛨ Security
Description
After ensure_fresh_token refuses an insecure endpoint or redirect, _resolve_scan_oauth_provider
still returns the SDK OAuth provider with the stored refresh token and client secret; the store's
own documentation confirms that provider can then perform its own unguarded refresh. This bypasses
both hardening controls and can send long-lived credentials over plaintext or through a redirect.
Code

src/agent_scan/mcp_client.py[R74-75]

+    await ensure_fresh_token(store, url)
+    return OAuthClientProvider(
Relevance

●● Moderate

Security concern is plausible, but no closely matching accepted or rejected precedent covers SDK
refresh behavior.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The scan constructs and returns the SDK provider immediately after the guarded proactive refresh.
The store docstring and inline comments explicitly state that a refused stale token is handed to the
SDK, whose own refresh is not covered by either security guard.

src/agent_scan/mcp_client.py[67-85]
src/agent_scan/oauth_store.py[426-434]
src/agent_scan/oauth_store.py[453-465]
src/agent_scan/oauth_store.py[476-484]

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 scan returns an OAuth provider containing refresh credentials even when the proactive refresh security checks refuse the endpoint, allowing the SDK refresh path to bypass HTTPS and redirect protections.

## Issue Context
The provider must not receive usable refresh credentials/client secrets unless every token request it can make is subject to the same endpoint and redirect policy.

## Fix Focus Areas
- src/agent_scan/mcp_client.py[67-85]
- src/agent_scan/oauth_store.py[426-434]
- src/agent_scan/oauth_store.py[453-484]

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



Remediation recommended

5. Windows writers lose updates ✗ Dismissed 🐞 Bug ☼ Reliability
Description
When fcntl is unavailable, _FileLock silently proceeds unlocked even though every token
operation performs a whole-document read/modify/replace through one shared .tmp path. Concurrent
scan or authentication processes on Windows can overwrite another server's update, collide on the
temporary file, or fail during os.replace, losing credentials or aborting the operation.
Code

src/agent_scan/oauth_store.py[R337-343]

+        except (ImportError, OSError):
+            # Best-effort: proceed without a lock. Atomic os.replace still
+            # guarantees readers never observe a partial write.
+            if self._fd is not None:
+                os.close(self._fd)
+                self._fd = None
+        return self
Relevance

●● Moderate

Cross-platform concurrent credential loss is a substantive reliability concern, but no closely
matching Windows locking precedent exists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
All public mutations read and rewrite the complete map, and _write_raw always uses the same .tmp
path. The lock context explicitly degrades to no lock when fcntl cannot be imported, so atomic
replacement cannot prevent lost read-modify-write updates or temporary-file collisions.

src/agent_scan/oauth_store.py[211-239]
src/agent_scan/oauth_store.py[241-248]
src/agent_scan/oauth_store.py[263-319]
src/agent_scan/oauth_store.py[323-354]

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 token store has no writer serialization on Windows, despite whole-file updates and a shared temporary pathname.

## Issue Context
Use a Windows-capable interprocess lock or another cross-platform serialization design; retain atomic replacement for reader safety and ensure concurrent updates to different keys are merged rather than lost.

## Fix Focus Areas
- src/agent_scan/oauth_store.py[211-239]
- src/agent_scan/oauth_store.py[241-248]
- src/agent_scan/oauth_store.py[263-319]
- src/agent_scan/oauth_store.py[323-354]

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



Informational

6. authenticate_server lacks API tests ✓ Resolved 📘 Rule violation ☼ Reliability
Description
The new public authenticate_server API has no test that invokes it, so neither its successful
connection/persistence path nor its error-return path is exercised. Existing OAuth-flow tests cover
only private transport, loopback, and storage helpers.
Code

src/agent_scan/oauth_flow.py[R240-243]

+async def authenticate_server(
+    url: str,
+    server_name: str,
+    store: OAuthTokenStore,
Relevance

●●● Strong

Recent precedent explicitly accepted adding success and error-path tests for newly introduced public
APIs.

PR-#279

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 4 requires new public APIs to have success and error tests. A repository test search finds no
invocation of authenticate_server; test_oauth_flow.py imports and tests only
_AuthFlowTokenStorage, _LoopbackCallbackServer, and _transport_strategy.

Rule 4: Every change must include automated tests; bug fixes add a regression test
src/agent_scan/oauth_flow.py[240-297]
tests/unit/test_oauth_flow.py[8-12]

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 new public `authenticate_server` coroutine is untested on both success and error paths.

## Issue Context
Mock transport connection and OAuth metadata so tests verify successful endpoint persistence, generic failure results, transport exhaustion, and loopback cleanup.

## Fix Focus Areas
- src/agent_scan/oauth_flow.py[240-297]
- tests/unit/test_oauth_flow.py[1-90]

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


7. mcp-auth exposes exception details ✗ Dismissed 📘 Rule violation ☼ Reliability
Description
authenticate_server stores the caught exception type and str(e) in AuthResult.message, which
the user-facing mcp-auth command prints verbatim. Network or SDK errors can therefore disclose
hostnames, URLs, response details, and other internals instead of a generic failure message.
Code

src/agent_scan/oauth_flow.py[R276-278]

+            except Exception as e:
+                last_error = f"{type(e).__name__}: {e}"
+                logger.debug("mcp-auth attempt failed (%s %s): %s", kind, attempt_url, last_error)
Relevance

●● Moderate

User-facing exception-detail leakage is credible, but nearby historical privacy and raw-detail
findings were rejected.

PR-#341
PR-#324

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 6 requires client-facing messages to avoid str(e), exception representations, hostnames, and
other internals. The new flow formats type(e).__name__ and e into last_error, returns it in
AuthResult, and the CLI prints result.message directly.

Rule 6: Handle errors explicitly; don't swallow; clean up; don't leak internals
src/agent_scan/oauth_flow.py[274-279]
src/agent_scan/cli.py[1243-1249]
src/agent_scan/debug_mcp_auth.py[55-59]

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 flow converts caught exceptions into a raw type-and-message string that is later printed to terminal users.

## Issue Context
Keep detailed exception context in internal logs, but return a stable generic `AuthResult.message` from the public command path. Ensure diagnostics do not print raw exception strings either.

## Fix Focus Areas
- src/agent_scan/oauth_flow.py[274-279]
- src/agent_scan/cli.py[1243-1249]
- src/agent_scan/debug_mcp_auth.py[55-59]

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


8. Transport probing logic duplicated ✗ Dismissed 📘 Rule violation ☼ Reliability
Description
_transport_strategy introduces another hard-coded six-attempt transport/URL matrix while
check_server retains its own near-identical matrix. The two implementations already use different
ordering, so future transport changes can make interactive authentication and scanning resolve
servers differently.
Code

src/agent_scan/oauth_flow.py[R209-215]

+    ordered = [
+        ("http", with_mcp),
+        ("http", base),
+        ("sse", with_sse),
+        ("sse", base),
+        ("http", with_sse),
+        ("sse", with_mcp),
Relevance

●● Moderate

The team sometimes accepts duplication cleanup, but transport-probing architecture changes remain
subjective and evidence is mixed.

PR-#203
PR-#327

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 5 requires near-identical logic to use a shared utility. The OAuth helper explicitly says it
mirrors check_server, while both locations separately assemble combinations of HTTP/SSE with the
base, /mcp, and /sse URLs.

Rule 5: Avoid duplication (DRY)—reuse utilities instead of copy-pasting
src/agent_scan/oauth_flow.py[195-224]
src/agent_scan/mcp_client.py[303-327]

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 authentication and normal server checking independently construct the same transport and URL probing combinations.

## Issue Context
Extract one shared strategy builder that accepts the configured transport and probing mode, then use it from both call sites while preserving intentional ordering through explicit parameters.

## Fix Focus Areas
- src/agent_scan/oauth_flow.py[195-224]
- src/agent_scan/mcp_client.py[303-327]

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


View low (4)
9. Query URLs are mangled ✓ Resolved 🐞 Bug ≡ Correctness
Description
_transport_strategy inspects only the parsed path but removes /mcp or /sse from the end of the
entire URL string, so a URL such as https://host/mcp?tenant=acme has query characters removed
instead of its path suffix. Every generated OAuth connection attempt is then malformed, preventing
mcp-auth from authenticating supported remote URLs that carry query parameters.
Code

src/agent_scan/oauth_flow.py[R201-206]

+    base = url.rstrip("/")
+    path = urlparse(base).path
+    if path.endswith("/sse"):
+        base = base[: -len("/sse")]
+    elif path.endswith("/mcp"):
+        base = base[: -len("/mcp")]
Relevance

● Weak

A closely matching URL-suffix/query-string normalization finding was explicitly rejected in recent
MCP transport code.

PR-#357

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new strategy uses urlparse(base).path to detect the suffix but slices base itself.
RemoteServer accepts arbitrary URL strings, and existing redaction code explicitly handles
remote-server query parameters, demonstrating that query-bearing URLs are supported repository
inputs.

src/agent_scan/oauth_flow.py[195-224]
src/agent_scan/models/mcp.py[82-98]
src/agent_scan/redact.py[659-670]

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 transport probing slices the complete URL based on a suffix found in only its parsed path, corrupting URLs with queries or fragments.

## Issue Context
Preserve query and fragment components while removing or appending transport suffixes only within the path component.

## Fix Focus Areas
- src/agent_scan/oauth_flow.py[195-224]
- src/agent_scan/oauth_store.py[88-102]

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


10. ensure_fresh_token exceeds limit 📘 Rule violation ☼ Reliability
Description
ensure_fresh_token spans about 90 physical lines and combines locking, store reads, expiry
validation, endpoint security checks, request construction, HTTP exchange, redirect handling,
parsing, and persistence. This exceeds the 40-line limit and makes the security-sensitive refresh
path harder to review and test independently.
Code

src/agent_scan/oauth_store.py[R417-420]

+async def ensure_fresh_token(store: OAuthTokenStore, server_url: str, *, timeout: float = 30.0) -> None:
+    """Refresh a stored access token before connecting, if it is expired.
+
+    Best-effort and non-fatal: any failure (network, dead refresh token,
Relevance

● Weak

Recent repository precedent consistently rejects decomposition requests based primarily on the
40-line function-length rule.

PR-#320
PR-#439

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 45 limits functions to at most 40 SLOC and asks that monolithic validation and I/O flows be
decomposed. The new function runs from line 417 through 505 and contains multiple independent
branches plus network and persistence operations.

Rule 45: Limit function length to ≤ 40 lines (SLOC)
src/agent_scan/oauth_store.py[417-505]

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

## Issue description
`ensure_fresh_token` is substantially longer than 40 SLOC and owns several distinct responsibilities.

## Issue Context
Keep the orchestration function short by extracting request construction, secure token-endpoint exchange, response handling, and token persistence into clearly named helpers.

## Fix Focus Areas
- src/agent_scan/oauth_store.py[417-505]

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


11. authenticate_server exceeds limit 📘 Rule violation ☼ Reliability
Description
authenticate_server exceeds 40 SLOC while creating the callback server, configuring the OAuth
provider, probing transports, translating failures, persisting discovered metadata, warning users,
and cleaning up. These responsibilities should be separated into focused helpers.
Code

src/agent_scan/oauth_flow.py[R240-243]

+async def authenticate_server(
+    url: str,
+    server_name: str,
+    store: OAuthTokenStore,
Relevance

● Weak

Multiple recent precedents reject refactoring functions solely to satisfy the 40-line guideline,
including substantial orchestration functions.

PR-#439
PR-#391

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 45 requires functions to remain at or below 40 SLOC with clear single-purpose helpers. The
added function spans lines 240-297 and performs several distinct setup, network, persistence,
reporting, and cleanup tasks.

Rule 45: Limit function length to ≤ 40 lines (SLOC)
src/agent_scan/oauth_flow.py[240-297]

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

## Issue description
`authenticate_server` exceeds the 40-SLOC limit and combines provider setup, probing, error translation, persistence, and UI behavior.

## Issue Context
Extract provider construction, one-strategy execution, and token-endpoint finalization while retaining the `finally` cleanup in the orchestration layer.

## Fix Focus Areas
- src/agent_scan/oauth_flow.py[240-297]

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


12. mcp_auth exceeds limit 📘 Rule violation ☼ Reliability
Description
mcp_auth exceeds 40 SLOC and combines argument interpretation, discovery, target validation, user
messaging, authentication execution, and result rendering. Splitting target resolution from
execution would keep the command handler focused.
Code

src/agent_scan/cli.py[R1196-1201]

+async def mcp_auth(args):
+    """Interactively authenticate an OAuth-protected remote MCP server.
+
+    Runs the browser OAuth flow and persists the token to the local store, so
+    subsequent (unattended) scans use and refresh it. This is the only command
+    that performs an interactive authorization; the scan path never does.
Relevance

● Weak

Recent repository precedent explicitly rejected refactoring oversized CLI handlers solely for the
line-count rule.

PR-#433
PR-#286

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 45 asks for extraction when a function exceeds about 40 logical lines and performs multiple
concerns. The new handler spans lines 1196-1249 and includes discovery, validation, execution, and
output behavior.

Rule 45: Limit function length to ≤ 40 lines (SLOC)
src/agent_scan/cli.py[1196-1249]

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 new `mcp_auth` command handler exceeds 40 SLOC and mixes target discovery and validation with authentication execution and presentation.

## Issue Context
Move URL/name/all-target resolution into a helper returning validated targets, and move per-target rendering or execution into a second focused helper.

## Fix Focus Areas
- src/agent_scan/cli.py[1196-1249]

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


Grey Divider

Context sources
✅ Compliance rules (platform): 8 rules

Grey Divider

Tip of the day
💡 Did you know, you can reply 'qodo' on any finding to push back, ask questions, or dig deeper

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/agent_scan/oauth_flow.py
Comment thread src/agent_scan/oauth_flow.py
Comment thread src/agent_scan/oauth_flow.py
Comment on lines +74 to +75
await ensure_fresh_token(store, url)
return OAuthClientProvider(

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

7. Sdk bypasses refresh guards 🐞 Bug ⛨ Security

After ensure_fresh_token refuses an insecure endpoint or redirect, _resolve_scan_oauth_provider
still returns the SDK OAuth provider with the stored refresh token and client secret; the store's
own documentation confirms that provider can then perform its own unguarded refresh. This bypasses
both hardening controls and can send long-lived credentials over plaintext or through a redirect.
Agent Prompt
## Issue description
The scan returns an OAuth provider containing refresh credentials even when the proactive refresh security checks refuse the endpoint, allowing the SDK refresh path to bypass HTTPS and redirect protections.

## Issue Context
The provider must not receive usable refresh credentials/client secrets unless every token request it can make is subject to the same endpoint and redirect policy.

## Fix Focus Areas
- src/agent_scan/mcp_client.py[67-85]
- src/agent_scan/oauth_store.py[426-434]
- src/agent_scan/oauth_store.py[453-484]

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 67bb08d: PersistentTokenStorage.get_tokens()/get_client_info() now withhold the refresh token and client secret from the SDK provider whenever the stored token endpoint fails is_secure_token_url — the same check ensure_fresh_token already applies — so the SDK's own internal refresh can no longer bypass that guard. The access token still flows through, so the connection attempt itself is unaffected. Covered by test_persistent_storage_withholds_refresh_credentials_for_insecure_endpoint.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correction to my reply above: 67bb08d closes the insecure-endpoint half only — when entry.token_url fails is_secure_token_url, the SDK now gets no refresh token at all, so it can't attempt a refresh in that case.

It does not close the redirect half this finding also names. If the stored token_url is secure but that endpoint responds to the SDK's own refresh POST with a 307/308, the SDK sends that request through the scan path's httpx.AsyncClient(..., follow_redirects=True) (mcp_client.py's streamablehttp_client_without_session) and would follow the redirect and resend the refresh token/client secret — the same risk ensure_fresh_token sets follow_redirects=False to avoid for its own refresh POST. That gap is pre-existing (documented in ensure_fresh_token's docstring as "not covered here and is tracked as a follow-up," predating this PR) and remains open; my fix didn't address it. Leaving this thread open rather than marking it fully resolved.

Comment thread src/agent_scan/pipelines.py Outdated
Comment thread src/agent_scan/cli.py Outdated
Comment thread src/agent_scan/redact.py Outdated
Comment thread src/agent_scan/oauth_store.py
gitaleksey added a commit that referenced this pull request Aug 25, 2026
- oauth_flow.py: add missing type: ignore on the monkey-patched
  HTTPServer.callback_received access
- cli.py: cast --server-type to its Literal type and annotate
  unresolved_paths so mypy resolves the single-server-scan branch
- mcp_client.py: ruff-format line reflow
- test_redact.py: replace a JWT-shaped fake bearer token fixture
  (GitGuardian flagged it as a real secret) with a non-JWT-shaped one
- test_oauth_store.py: guard the 0600 permission assertion with the
  same win32 check its sibling tests already use (NTFS has no POSIX
  mode bits)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gitaleksey added a commit that referenced this pull request Aug 25, 2026
- oauth_store.py, oauth_flow.py: normalize_server_url and
  _transport_strategy sliced a transport suffix off the raw URL string,
  mangling any query string (e.g. ?tenant=acme). _transport_strategy now
  delegates to normalize_server_url instead of duplicating the logic.
- redact.py: bearer-token redaction only matched "Bearer"/"bearer";
  auth schemes are case-insensitive per RFC 7235 s2.1, so "BEARER" or
  "BeArEr" tokens could leak past the redaction boundary.
- pipelines.py: filter_clients_to_server kept every entry matching
  --server NAME across all clients/config files instead of the first
  occurrence, so a duplicated name could scan multiple distinct servers.
  Now matches discover_servers_by_name's first-occurrence-wins policy.
- cli.py: mcp-auth always exited 0, even on auth failure or an unknown
  server name, so scripts couldn't detect failure. It now returns/
  propagates a real exit status.
- oauth_store.py: PersistentTokenStorage handed the SDK's
  OAuthClientProvider a live refresh token and client secret even when
  ensure_fresh_token had refused to refresh against an insecure token
  endpoint, letting the SDK's own unguarded refresh bypass that
  protection. It now withholds both when the stored endpoint isn't
  HTTPS/loopback.
- oauth_flow.py: added success/failure-path tests for authenticate_server,
  previously untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Two more findings from @qodo-merge-etso's summary that didn't get their own inline threads:

Replies to the 8 items that did get inline threads are posted there. Summary: 5 real bugs fixed with regression tests (query-string mangling in 2 places, case-sensitive bearer-token redaction, duplicate-server-name targeting, mcp-auth's always-zero exit code, and the SDK's refresh-guard bypass), plus the missing authenticate_server tests added. Pushing back on the 40-SLOC asks, the exception-detail message, and the Windows file-lock gap, with reasoning in the respective threads/above. Full suite + pre-commit green.

@qodo-merge-etso

Copy link
Copy Markdown

Two more findings from @qodo-merge-etso's summary that didn't get their own inline threads:

Replies to the 8 items that did get inline threads are posted there. Summary: 5 real bugs fixed with regression tests (query-string mangling in 2 places, case-sensitive bearer-token redaction, duplicate-server-name targeting, mcp-auth's always-zero exit code, and the SDK's refresh-guard bypass), plus the missing authenticate_server tests added. Pushing back on the 40-SLOC asks, the exception-detail message, and the Windows file-lock gap, with reasoning in the respective threads/above. Full suite + pre-commit green.

Thanks for the detailed disposition. The query-string fix is reflected as finding 9 (implemented), including both normalize_server_url and _transport_strategy; keeping the older check_server issue out of scope is reasonable given its pre-existing status and prior rejection.

The line-count objections remain findings 10, 11, and 12 (active). Given the cited precedent and the functions’ orchestration responsibilities, leaving them unchanged is consistent with the repository’s established practice.

Your summary also indicates the SDK refresh-guard bypass and missing authentication tests were fixed; the current findings index still shows finding 4 and finding 6 as implemented, so those are accounted for. No further action is needed from me based on this update.

@gitaleksey

gitaleksey commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@SH4DY , the security finding is actually from the test for fixing case sensitivity on bearer tokens. Pre-existing.

@SH4DY SH4DY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall as discussed on the call: Please check if you can easily store the refresh tokens encrypted instead of plaintext on file.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Remove this file please

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@SH4DY , token encrypted. Decryption key is currently in the same location. As discussed, a mechanism for this to be implemented separately

*path* only, so a query string or fragment (e.g. ``?tenant=acme``) is
preserved rather than sliced off along with the suffix.
"""
split = urlsplit(url)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

normalize_server_url strips /mcp and /sse to build the store key, so https://host/mcp, https://host/sse and https://host/ all share one credential. I authenticated only /mcp and a different service at /sse on the same origin received the bearer. Key on the full URL and handle probe aliasing by trying candidate keys instead.

"""
store = OAuthTokenStore()
entry = store.get(url)
if entry is None and token is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

seeding the store from --mcp-oauth-tokens-path silently copies a caller-supplied access + refresh token into ~/.mcp-scan/oauth-tokens.json, this needs to be documented in docs/

Comment thread src/agent_scan/cli.py
setup_scan_parser(mcp_auth_parser, add_files=False)
add_target_arguments(mcp_auth_parser, positional=True, include_type=False)
mcp_auth_parser.add_argument(
"--all-unauthenticated", action="store_true", help="Authenticate every discovered remote MCP server"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Whats the intention of this flag? The name and help don't really match

)
else:
logger.warning("Authenticated %s but no token endpoint was discovered", url)
return AuthResult(ok=True, server_url=normalize_server_url(url))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

returns ok=True whenever the MCP session initializes, so a server needing no auth prints : authenticated with nothing written to the store. Gate success on a token having been persisted.

eftanzer and others added 10 commits August 26, 2026 15:57
Let unattended scans authenticate to OAuth-protected remote MCP servers, and
add an `mcp-auth` command for the one-time interactive authorization. Removes
the manual mcp-remote + hand-assembled-token-file workaround.

- oauth_store.py (new): read-write token store at ~/.mcp-scan/oauth-tokens.json
  (0600), keyed by normalized server URL, with proactive refresh + write-back
  so a token authenticated once survives across unattended scan invocations.
- oauth_flow.py (new) + `mcp-auth` CLI command: browser + 127.0.0.1 loopback
  OAuth authorization; persists the token for scans to consume. The only
  interactive path.
- mcp_client.py: scan path is a non-interactive consumer of the store over both
  HTTP and SSE transports; never prompts, degrades to auth_failed when there is
  no valid credential.
- redact.py: scrub bearer tokens from uploaded server_output (defense in depth).

Credentials stay on the machine; nothing new is sent to the platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- create the token file 0600 before writing, not after (was 0644 during write)
- stop the debug helper printing access/refresh tokens and client secrets
- do not follow redirects on the token exchange (httpx re-sends the body on 307/308)
- require HTTPS or loopback for the token endpoint before sending a refresh token
- oauth_flow.py: add missing type: ignore on the monkey-patched
  HTTPServer.callback_received access
- cli.py: cast --server-type to its Literal type and annotate
  unresolved_paths so mypy resolves the single-server-scan branch
- mcp_client.py: ruff-format line reflow
- test_redact.py: replace a JWT-shaped fake bearer token fixture
  (GitGuardian flagged it as a real secret) with a non-JWT-shaped one
- test_oauth_store.py: guard the 0600 permission assertion with the
  same win32 check its sibling tests already use (NTFS has no POSIX
  mode bits)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- oauth_store.py, oauth_flow.py: normalize_server_url and
  _transport_strategy sliced a transport suffix off the raw URL string,
  mangling any query string (e.g. ?tenant=acme). _transport_strategy now
  delegates to normalize_server_url instead of duplicating the logic.
- redact.py: bearer-token redaction only matched "Bearer"/"bearer";
  auth schemes are case-insensitive per RFC 7235 s2.1, so "BEARER" or
  "BeArEr" tokens could leak past the redaction boundary.
- pipelines.py: filter_clients_to_server kept every entry matching
  --server NAME across all clients/config files instead of the first
  occurrence, so a duplicated name could scan multiple distinct servers.
  Now matches discover_servers_by_name's first-occurrence-wins policy.
- cli.py: mcp-auth always exited 0, even on auth failure or an unknown
  server name, so scripts couldn't detect failure. It now returns/
  propagates a real exit status.
- oauth_store.py: PersistentTokenStorage handed the SDK's
  OAuthClientProvider a live refresh token and client secret even when
  ensure_fresh_token had refused to refresh against an insecure token
  endpoint, letting the SDK's own unguarded refresh bypass that
  protection. It now withholds both when the stored endpoint isn't
  HTTPS/loopback.
- oauth_flow.py: added success/failure-path tests for authenticate_server,
  previously untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- utils.py: a set-but-empty env var (e.g. AUTH_HEADER=) now counts as
  unset too, so it warns and leaves the placeholder unexpanded instead
  of silently substituting a blank credential.
- utils.py: restore the docstring's --verbose qualifier -- default
  logging suppresses the warning, so "fails loudly" overstated it.
- consent.py: the interactive consent prompt now shows a whole-value
  ${NAME} placeholder literally instead of masking it as ***, since the
  placeholder itself isn't a secret -- it discloses which of the user's
  own env vars a server is about to read before they approve it.
- docs/scanning.md: promote the new section out of the version-specific
  data-sharing list, document that a scanned config's env block can now
  pull arbitrary variables from the scanning user's shell (not just
  values written on disk), and note there's no escape syntax for a
  literal ${NAME}.
- test_mcp_client.py: assert args stay unexpanded (only env is), the
  one previously-unpinned scope boundary from the plan.
- test_consent.py: new, covers _render_env_redacted's masked vs.
  placeholder-literal branches (this module had no tests before).
@gitaleksey
gitaleksey force-pushed the feat/oauth-resolution branch from 67bb08d to 6058c4d Compare August 26, 2026 23:49
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.

3 participants