Skip to content

fix(auth): make credential validation failures diagnosable - #385

Merged
Max17190 merged 5 commits into
mainfrom
fix/introspect-failure-diagnostics
Aug 25, 2026
Merged

fix(auth): make credential validation failures diagnosable#385
Max17190 merged 5 commits into
mainfrom
fix/introspect-failure-diagnostics

Conversation

@Max17190

@Max17190 Max17190 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Why

Hosted MCP validates the caller's credential on every request. When that check does not complete cleanly, the server returns one 503 with one sentence and writes nothing to its log.

Seven distinct conditions share that response: the introspection secret is unset, the request fails at the transport, the abort budget fires, the endpoint answers with a status that is not 2xx, it answers with something that is not JSON, the body has no usable boolean active, or the body is well formed and describes a credential that cannot be used on this resource. Every one of them produced the same bytes.

That has two costs. Operators cannot tell an upstream outage from a configuration fault after the fact, so every occurrence turns into an investigation from scratch. And the client is told the service is unavailable with no indication of how long to wait, in a response shaped like an OAuth error, which reads as a verdict on the credential it just presented.

One case did not even reach that path. A body that failed to parse, or that parsed to null, threw past every tagged error and surfaced as a 401 with WWW-Authenticate carrying the raw parser message:

{"error":"invalid_token","error_description":"Cannot read properties of null (reading 'active')"}

An upstream fault pushing a working session into reauthorization is the exact failure this response shape exists to avoid.

Summary

Server side, every throw site is built through one factory that emits a record and returns the error:

[MCP_CREDENTIAL_VALIDATION] {"aborted":null,"elapsed_ms":8,"introspect_status":401,"reason":"introspect_http_status","resource":"https://mcp.firecrawl.dev/v2/mcp-oauth"}

The record is emitted where the error is raised, not where it is caught. These come from two different call stacks: request authentication, and outbound client setup during tool execution. A single catch site covers only the first and silently drops any site added later.

Deliberately low cardinality. resource is one of a handful of server owned URLs. The token, the resolved API key, the upstream response body and its headers, request URLs, and user agents are never included, matching the policy already documented on the neighbouring auth telemetry.

Introspection bodies are now parsed defensively, so an unparseable or null body is tagged introspect_malformed_body and answered like every other unusable result, using the same guard this file already applies to other upstream JSON reads.

Client side, the response changes shape but not status:

Before After
Status 503 503
Retry-After absent 5
WWW-Authenticate absent absent
Body {"error":"temporarily_unavailable","error_description":"..."} Firecrawl credential validation is temporarily unavailable

Retry-After states a concrete wait, which RFC 9110 15.6.4 provides for on a 503. The reference MCP client does not read that header today, so this is a contract correction rather than a behaviour change at the client. The OAuth error code is dropped because RFC 6749 reserves those for 400 and 401 responses, and clients surface them as authentication failures.

The status stays 503 and WWW-Authenticate stays absent for a concrete reason: in @modelcontextprotocol/sdk, a 401 with an auth provider present re-runs the OAuth flow and retries the request, while any other status is surfaced verbatim. An upstream fault must not trigger that flow. The same code path reads the error body with .text(), never .json(), so changing the body from JSON to the sentence alone is safe and shortens what the user sees to:

Error POSTing to endpoint: Firecrawl credential validation is temporarily unavailable

All nine reason tags reach the log: introspect_secret_missing, introspect_transport_error, introspect_http_status, introspect_content_type, introspect_malformed_body, introspect_unusable_credential, delegated_signing_secret_missing, outbound_client_uninstrumented, delegated_credential_unavailable.

delegated_credential_unavailable is unreachable and now says so in a comment. The interceptor holding it is installed only for a session carrying a managed key, and signing such a session always produces a non-empty credential or throws while signing. The branch stays because the signing helper returns string | undefined, so removing it would mean asserting instead of checking.

Out of scope on purpose: no caching, no change to the abort budget, and no change to which credentials are introspected.

Test plan

npm test (build, then 75 tests, all passing). npm run lint and npx tsc --noEmit clean.

Coverage in tests/mcp-smoke.test.mjs:

  • A shared assertion pins the client contract on every path: 503, Retry-After: 5, no WWW-Authenticate, body equal to the sentence, and no temporarily_unavailable anywhere in it.
  • each credential validation failure logs its own reason and status drives six cases through one server and asserts the emitted reason, introspect_status, profile, aborted, and a numeric elapsed_ms for each: a status that is not 2xx, a non JSON content type, a body whose active is not boolean, a null body, a truncated body, and a clean body describing an unusable credential.
  • The same test asserts the client token, the resolved API key, and the introspection secret never appear anywhere in the server output.
  • The factory was exercised in isolation to confirm it emits with no catch site in play, which is the path the getClient tags take.
  • credential validation outages do not misdirect clients into OAuth also asserts that an unreachable endpoint logs introspect_transport_error with aborted: false and a null status.

Verified against a build of main side by side, running the same 19 scenarios through both binaries with the per request SSE event id normalized:

  • 7 non 503 paths byte identical, including healthy fco_ and fc- sessions, keyless, the invalid key recovery session, both 401 challenge paths, and a real tools/call that reached the upstream API exactly once.
  • 12 paths changed, all of them the intended 503 reshape.
  • 0 unexpected changes.
  • The abort budget path was exercised with an endpoint sleeping past it, recording aborted: true and elapsed_ms: 1506.
  • No credential material in server output in any scenario.

Every failed credential check returned the same 503 body with no server
log, so an introspection outage, a missing secret, and a credential that
introspected cleanly but is unusable here were indistinguishable after
the fact.

Tag each throw site with a reason and emit one low cardinality server log
line carrying the reason, the introspection HTTP status, elapsed ms, and
whether the request was aborted by its own budget. Never log the token,
the resolved API key, or the upstream body.

Keep the 503 and the existing sentence. Add Retry-After so the client has
a concrete wait, and drop the OAuth error code, which RFC 6749 reserves
for 400 and 401 responses and which clients surface as an authentication
verdict. No WWW-Authenticate: this is not a verdict on the credential.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/index.ts
Comment thread src/index.ts
An introspection body that failed to parse, or that parsed to null,
threw past every tagged error in introspectToken. It reached the client
as a 401 with WWW-Authenticate and the raw parser message in
error_description, so an upstream fault pushed a working session into
reauthorization and no reason was logged.

Parse defensively and treat a missing boolean `active` as
introspect_malformed_body, matching the guard this file already uses on
other upstream JSON reads. Also scope the telemetry doc comment: it
covers rejections raised while authenticating, not the outbound client
tags raised during tool execution.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@hmishra2250

Copy link
Copy Markdown
Collaborator

Closed #384 in favour of this one, we were solving the same problem and yours goes further. Nice catch on the null body, I had missed that it slips past the tagged branches and comes back as a 401 challenge. Also verified your read on the client contract change independently: the SDK pulls the error body with .text() and only re-runs the auth flow on a 401 or 403, so dropping the OAuth code and going to plain text is safe.

One small thing worth considering. emitCredentialValidationFailure only gets called from the makeAuthenticate catch, so the two getClient tags, outbound_client_uninstrumented and delegated_credential_unavailable, never actually make it into the log. You call that out in the doc comment so I know it's deliberate, but it does mean two of the nine reasons are labels only, and if either ever fires we'd be back to guessing.

The approach I'd taken was to log at the point the error is created rather than where it's caught, which covers every site for free no matter where it's thrown from. Might be worth folding in, though it's also fine as a follow up if you'd rather keep this PR tight.

Rest looks good to me. Ran your branch locally, 75/75 passing.

@hmishra2250
hmishra2250 self-requested a review August 25, 2026 09:03

@hmishra2250 hmishra2250 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.

LGTM!

Logging from the authenticate catch covered only one of the two call
stacks that raise this error. The outbound client checks in getClient run
during tool execution, so their reasons were labels that could never
appear in a log, and any throw site added later would have been dropped
the same way.

Build the error through a factory that emits the record and returns it.
Every site is covered regardless of where it is caught or whether it is
caught at all.

Replace the profile field with the resource being validated. Profile
describes the request rather than the error, it is not available where
the error is raised, and the resource URL is both more precise and
already carried into introspection.

Note that delegated_credential_unavailable is unreachable: the
interceptor is installed only for a session holding a managed key, and
signing such a session always yields a non-empty credential or throws.
It stays because the signature is string | undefined, so dropping it
would mean asserting rather than checking.
@Max17190

Max17190 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Folded the nit in as 18f9bc4. Errors are now built through a factory that emits the record and returns it, so all nine reasons log regardless of where the error is caught.

Two notes: I swapped profile for the resource being validated, since profile describes the request rather than the error and is not reachable from the throw site. And delegated_credential_unavailable turns out to be unreachable, so it now carries a comment saying so.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/session-credential.ts">

<violation number="1" location="src/session-credential.ts:100">
P3: When the delegated signing secret is missing, the [MCP_CREDENTIAL_VALIDATION] record now logs `resource: null`, losing the profile context the removed logger previously included. `delegationSecret()` raises this during request authentication (via `requireDelegatedCredentialSigning()` in `resolveCredentialFromHeaders`) with no `resource`, unlike the `introspect_*` tags that carry it. This regresses diagnosability for that one path. Thread the profile/resource through to this throw so the emitted record is not blank.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/session-credential.ts
That path was the one record emitted with no context beyond its reason.
Pass the resource the token was validated against, using expectedAudience
rather than the profile URL so the legacy audience fallback is reflected.

The parameter is optional because outbound signing has no resource in
scope and should not carry a misleading one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

0 issues found across 3 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

Constructing the class directly compiles and silently skips the record,
which is the regression this PR exists to close. Say so where someone
adding a throw site will read it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Requires human review: Alters the public HTTP response contract of the credential-validation endpoint: body changes from JSON to plain text, adds Retry-After, and drops the OAuth error code, so a human should approve the contract change.

Re-trigger cubic

@Max17190
Max17190 merged commit 834324d into main Aug 25, 2026
2 checks passed
@PhantomInTheWire
PhantomInTheWire deleted the fix/introspect-failure-diagnostics branch August 27, 2026 15:16
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.

2 participants