Skip to content

feat: add auth and resilience scenarios#36

Merged
daranium2020 merged 6 commits into
mainfrom
feat/auth-resilience-v0.3.0
Jul 19, 2026
Merged

feat: add auth and resilience scenarios#36
daranium2020 merged 6 commits into
mainfrom
feat/auth-resilience-v0.3.0

Conversation

@daranium2020

Copy link
Copy Markdown
Owner

Summary

Implements v0.3.0 config-driven scenario runner with RFC 6750-compliant auth classification and explicit retry categories.

Auth classification

Finding code Condition
AUTH_REQUIRED 401 with no credentials supplied
AUTH_INVALID 401 with credentials + any rejection, including error="invalid_token" (covers expired, revoked, and malformed per RFC 6750 §3.1)
AUTH_EXPIRED 401 with credentials + unambiguous expiry indicator only: error="expired" or error="token_expired"
AUTH_FORBIDDEN 403 regardless of credentials

The key correctness fix: error="invalid_token" now produces AUTH_INVALID, not AUTH_EXPIRED. RFC 6750 §3.1 explicitly defines that code as covering expired, revoked, AND malformed tokens — it is too broad to classify as expiry-specific.

Retry semantics (explicit opt-in, off by default)

Condition retryOn category Default
HTTP 429 (RATE_LIMITED) rate-limit off
5xx responses (REMOTE_HTTP_ERROR) server-error off
Connection failures and connect timeouts connection-failure off
Response timeouts response-timeout off
401, 403 Never retried
400, schema errors, malformed MCP Never retried
Scenario timeout Never retried

A retry fires only when maxAttempts > 1 AND the failure category is listed in retryOn.

Config file support (mcp-release.config.yml)

version: 1
endpoint: https://api.example.com/mcp
headers:
  Authorization: Bearer ${API_TOKEN}
timeouts:
  connectMs: 5000
  responseMs: 10000
retries:
  maxAttempts: 3
  backoffMs: 500
  retryOn:
    - rate-limit
    - server-error
scenarios:
  - name: healthy
    expect:
      result: pass
  - name: missing-auth
    removeHeaders:
      - Authorization
    expect:
      httpStatus: 401
  • ${VAR_NAME} placeholders resolved from environment variables at runtime
  • Per-scenario header overrides and removeHeaders
  • Structured retries block with explicit opt-in categories

Reporting

  • Terminal: shows 2/3 attempts, rate-limit retry on retried scenarios
  • Markdown: adds Retry column to scenario summary table
  • JSON: round-trips maxAttempts and retryCategory fields
  • GitHub Actions: sets step outputs for config-mode runs

Other additions

  • HTTP 429 Retry-After parsing (integer seconds and HTTP-date)
  • RATE_LIMITED finding code with remediation guidance
  • Timeout classification: CONNECT_TIMEOUT vs RESPONSE_TIMEOUT
  • Credential redaction in all report formats
  • 883 tests pass across 41 test files

Test plan

  • pnpm typecheck — clean
  • pnpm lint — clean
  • pnpm test — 883/883 pass (41 test files)
  • pnpm build — clean, all packages including apps/web
  • No conflict markers in source files
  • All package versions remain at 0.2.1 (no version bump)
  • No .env.local or credentials exposed
  • Web app SSRF protections unchanged

Files changed

New files (13): fixtures/servers/src/auth-scenarios.ts, packages/cli/src/config.ts, packages/cli/tests/config.test.ts, packages/core/src/config-report.ts, packages/core/src/rate-limit.ts, packages/core/src/scenario-runner.ts, packages/core/tests/auth-invalid.test.ts, packages/core/tests/rate-limit.test.ts, packages/core/tests/scenario-runner.test.ts, packages/reporter/src/config-json.ts, packages/reporter/src/config-markdown.ts, packages/reporter/src/config-terminal.ts, packages/reporter/tests/config-reporter.test.ts

Modified files (22): README.md, apps/web/src/app/docs/page.tsx, apps/web/tests/ui/DocsPage.test.tsx, and core/cli/reporter source and test files

🤖 Generated with Claude Code

Implements v0.3.0 config-driven scenario runner with auth classification
and explicit retry categories.

Auth classification (RFC 6750 compliant):
- AUTH_REQUIRED: 401 with no credentials supplied
- AUTH_INVALID: 401 with credentials + any rejection, including
  error="invalid_token" (covers expired, revoked, and malformed — too
  broad for expiry-specific classification per RFC 6750 §3.1)
- AUTH_EXPIRED: 401 with credentials + unambiguous expiry indicator
  only: error="expired" or error="token_expired"
- AUTH_FORBIDDEN: 403 regardless of credentials

Retry semantics (explicit opt-in, off by default):
- Four categories: rate-limit (429), server-error (5xx/REMOTE_HTTP_ERROR),
  connection-failure (TRANSPORT_ERROR/CONNECT_TIMEOUT),
  response-timeout (RESPONSE_TIMEOUT)
- Requires both maxAttempts > 1 and category listed in retryOn array
- Auth errors (401/403), schema errors, and malformed MCP are never retried
- Reports show retry category, attempts performed, and max attempts

Config file support (mcp-release.config.yml):
- YAML-driven scenario definitions with per-scenario header overrides
  and removeHeaders
- ${VAR_NAME} placeholder resolution from environment variables at runtime
- Structured retries block: maxAttempts, backoffMs, retryOn categories

Reporting:
- Terminal: shows "2/3 attempts, rate-limit retry" on retried scenarios
- Markdown: adds Retry column to scenario summary table
- JSON: round-trips maxAttempts and retryCategory fields
- GitHub Actions: sets step outputs for config-mode runs

Other:
- HTTP 429 Retry-After parsing (integer seconds and HTTP-date)
- Rate limit detection and RATE_LIMITED finding code
- Timeout classification: CONNECT_TIMEOUT vs RESPONSE_TIMEOUT
- Credential redaction in all report formats
- 883 tests pass (41 test files)

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

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mcp-release Ready Ready Preview, Comment Jul 19, 2026 11:29pm

…EOUT

The outer `timeoutPromise` in `connectToMcpServer` was registered before
the inner `responseTimedOut` timer in `fetchChain`. When both were set to
the same delay (the default, since `responseTimeoutMs` falls back to
`timeoutMs`), the outer promise won the `Promise.race` and produced
CONNECT_TIMEOUT even after a TCP connection was already established.

Fix: add +1 ms to the outer timer so the inner per-request timer always
fires first when the delays are equal. The inner timer still fires at
`responseTimeoutMs` ms; the outer backstop fires at `timeoutMs + 1` ms,
so the boundary is correctly preserved when `responseTimeoutMs` is
explicitly set higher than `timeoutMs`.

Updated tests assert exact finding codes:
- RESPONSE_TIMEOUT when inner fires first (default or responseTimeoutMs < timeoutMs+1)
- CONNECT_TIMEOUT when outer fires first (responseTimeoutMs > timeoutMs+1)
- SCENARIO_TIMEOUT fires at deadline and is never accompanied by RETRY_EXHAUSTED
- Category isolation: RESPONSE_TIMEOUT only retried with "response-timeout",
  CONNECT_TIMEOUT only retried with "connection-failure"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lose hang

Three bugs fixed:

1. AUTH_INVALID/AUTH_EXPIRED: credential test scenarios were not sending an
   Authorization header, so the server saw no credentials and returned
   AUTH_REQUIRED instead of AUTH_INVALID/AUTH_EXPIRED. Add requestHeaders
   with Bearer tokens in the new automated scenario-runner tests.

2. SCENARIO_TIMEOUT attempts: the retryCategory field persisted the last-used
   retry category even when the deadline fired before the second request
   started. Clear usedRetryCategory = null in the deadline branch so the
   result omits retryCategory when SCENARIO_TIMEOUT fires.

3. startConnectTimeoutServer hang: net.Server.close() only stops accepting
   new connections — it waits for all existing connections to close before
   invoking its callback. The server accepted TCP sockets but never closed
   them, causing server.close() to block indefinitely (test timeout). Track
   accepted sockets in a Set and destroy them explicitly when closing.

Also:
- Add AUTH_INVALID and AUTH_EXPIRED automated tests to scenario-runner.test.ts
  with explicit credential headers and negative exclusion assertions
- Update cli-verify.test.ts: HTTP localhost now always produces RESPONSE_TIMEOUT
  (tcpConnected=true immediately), so assert RESPONSE_TIMEOUT directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…requests)

Previously, SCENARIO_TIMEOUT was only checked at the top of each retry
iteration — between requests, not during them. This meant a hanging TCP
connection or slow HTTP response could blow past the scenario budget with no
way to abort it.

New architecture:
- One AbortController is created per scenario when scenarioTimeoutMs > 0.
- Its signal is passed to every runCheck() call via CheckOptions.scenarioSignal
  and threaded into connectToMcpServer via ConnectOptions.scenarioSignal.
- In transport.ts, the signal is included in fetchChain's merged AbortSignal
  so it aborts any in-flight fetch immediately.
- A scenarioDeadlinePromise also races alongside connectPromise and
  timeoutPromise, catching the case where the deadline fires before the
  first HTTP request starts (e.g. during TCP/TLS establishment).
- When the scenario signal fires, ScenarioTimeoutTransportError is thrown,
  which check.ts classifies as SCENARIO_TIMEOUT (not CONNECT_TIMEOUT or
  RESPONSE_TIMEOUT — the outer timer also checks the signal at fire-time
  to ensure the scenario deadline always wins).
- Backoff sleeps are replaced with abortableSleep() so a deadline expiring
  during backoff aborts the sleep and prevents the next request from starting.
- The scenario AbortController timer is cleared in a try/finally block to
  prevent timer leaks after the scenario settles.

Precedence rules (deterministic):
  scenarioTimeoutMs fires first → SCENARIO_TIMEOUT
  connectMs fires first, TCP not yet connected → CONNECT_TIMEOUT
  responseMs fires first after connect → RESPONSE_TIMEOUT

New exact tests:
- SCENARIO_TIMEOUT during hanging first attempt (scenarioMs < responseMs)
- SCENARIO_TIMEOUT during TLS connect (scenarioMs < connectMs)
- RESPONSE_TIMEOUT when responseMs fires before scenario budget
- SCENARIO_TIMEOUT after retries consume budget (fires mid-attempt, attempts=2)
- SCENARIO_TIMEOUT when deadline fires during backoff (attempts=1)
- No RETRY_EXHAUSTED alongside SCENARIO_TIMEOUT
- Subsequent scenario completes promptly (no leaked handles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rebuilds the bundled dist after the transport/check/scenario-runner
changes landed in the two preceding commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The old code was auth-specific but the mismatch finding fires for any
scenario type (rate-limit, timeout, server-error, successful pass). The
new generic code SCENARIO_MISMATCH is emitted whenever a scenario's
configured expect block does not match the actual outcome, regardless of
the failure category.

Changes:
- FindingCode enum: AUTH_SCENARIO_MISMATCH → SCENARIO_MISMATCH
- scenario-runner.ts: emit SCENARIO_MISMATCH in the mismatch branch
- remediation.ts, config-terminal.ts: update key/filter
- Tests: rename existing assertions, add 5 new exact-code tests
  (unexpected 401, unexpected 403, retry exhaustion, timeout mismatch,
  matched negative — no SCENARIO_MISMATCH)
- README, /docs: update code name in documentation
- Rebuilt github-action dist bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@daranium2020
daranium2020 merged commit 368d130 into main Jul 19, 2026
3 checks passed
@daranium2020
daranium2020 deleted the feat/auth-resilience-v0.3.0 branch July 19, 2026 23:33
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