Blog • Agent Security

After an MCP client sends OAuth credentials to the wrong authorization server, which JSON log step should stop the tool call?

On 30 September 2026 the MCP TypeScript SDK published GHSA-6qxp-vccf-f47h, severity High, CVSS 7.5. In affected versions the OAuth client did not bind saved credentials to the authorization server they belong to. An MCP server in the connection could name a different authorization server, and the client would send the credentials with no user action. What you keep after the advisory is the JSON line that stops the tool call, not a request you can replay.

Who owns MCP permissions is in the MCP security article. The permission decision after a schema pass is in the previous article. This one starts from the log after the advisory: if credential_check does not pass, the later tool call should not be there.

The decision: if the issuers differ, stop at credential_check. decision is deny, credentialsSent is false, and no tool call follows.

What is the first log line after the advisory?

Affected apps use the SDK as an OAuth client over HTTP, may connect to an MCP server they do not fully trust, and hold credentials for a legitimate authorization server. The entry points are an authProvider on a transport, the withOAuth middleware, and direct calls to auth or fetchToken. MCP servers built with this SDK, and stdio clients, are outside this advisory. On 1.x, 1.12.0 through 1.30.1 are fixed in 1.31.0. On 2.x, client 2.0.0 through 2.1.0 is affected only for bundled providers without expectedIssuer, stored credentials without issuer, direct fetchToken calls, or providers that read storage back through OAuthTokensSchema or OAuthClientInformationSchema. That line is fixed in 2.2.0. If you import core directly, upgrade it to 2.2.0 as well. The advisory is GHSA-6qxp-vccf-f47h.

The stop you keep when the issuers differ. Both addresses are placeholders, not servers to connect to.
{
  "step": "credential_check",
  "savedIssuer": "https://auth.example.com",
  "namedAuthorizationServer": "https://other.example.net",
  "issuerMatch": false,
  "credentialsSent": false,
  "decision": "deny",
  "reason": "issuer_mismatch"
}

This line records only the stop. savedIssuer is the authorization server the credentials belong to. namedAuthorizationServer is the one this MCP server names. When the two strings differ, issuerMatch is false. decision is deny and credentialsSent is false. Do not put a token or a client secret in the fields. Vulnerability listings record the same issue as CVE-2026-104850. This article follows the versions and the fix in the GitHub advisory.

Which schema pins the shape of this JSON line?

Use JSON Schema to pin the enums and the required fields. It proves this is a credential_check. decision may only be allow, deny, or ask. reason may only be issuer_mismatch, issuer_match, or awaiting_user. It does not prove that this step should stop.

The shape of the stop. issuerMatch false with decision allow still passes these enums.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["step", "savedIssuer", "namedAuthorizationServer", "issuerMatch", "credentialsSent", "decision", "reason"],
  "properties": {
    "step": { "enum": ["credential_check"] },
    "savedIssuer": { "type": "string" },
    "namedAuthorizationServer": { "type": "string" },
    "issuerMatch": { "type": "boolean" },
    "credentialsSent": { "type": "boolean" },
    "decision": { "enum": ["allow", "deny", "ask"] },
    "reason": { "enum": ["issuer_mismatch", "issuer_match", "awaiting_user"] }
  }
}

A schema pass only means the fields sit in the enums. issuerMatch false, credentialsSent true, and decision allow is still a valid shape. What stops the send is the app rule in the next section, not this shape check.

Which three rules still stop the step after the shape passes?

The fix in the advisory is this: the client records the authorization server as issuer on the credentials and does not send them to a different one. The user signs in again, or the call fails. The comparison sits outside the model. Whatever the model says later cannot turn a step already marked deny into a send.

What the record showsSchemaApp rule
issuerMatch is false, but credentialsSent is truePasses. true is a boolean.Set it back to false. Do not send the refresh_token, the client_secret, or a signed assertion.
issuerMatch is false, but decision is allowPasses. allow is in the enum.Change it to deny and keep reason as issuer_mismatch.
credential_check is already deny, and a tool_call line followsOne schema cannot see the next line. Changing step to tool_call fails this record itself.The log ends at credential_check. Do not append a tool call. A later call whose issuers already match gets its own permission decision.

After the version upgrade, which three settings do you still write by hand?

Upgrade 1.x to @modelcontextprotocol/sdk 1.31.0 or later. Upgrade 2.x to @modelcontextprotocol/client 2.2.0 or later. The package upgrade does not finish the three rows below. The source is still the same advisory.

What is still leftWhat the advisory requires
Bundled providersPass expectedIssuer to ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider, and CrossAppAccessProvider, for example https://auth.example.com. Without it they still use whichever authorization server the MCP server names.
Stored credentials without issuerTokens and client information saved in a file, keychain, or database before the upgrade still go to whichever authorization server is named at first use. Add issuer, or clear them so the user signs in again. Your own OAuthClientProvider must save exactly what saveTokens and saveClientInformation are given, including issuer. Pre-registered credentials include issuer in what clientInformation returns.
A new interactive sign-inThis fix does not cover a new interactive sign-in. That sign-in still goes to the authorization server the MCP server names. Complete it only for servers you trust. If the client may have connected to an untrusted MCP server, rotate the client secret or signing key and revoke the tokens. If you cannot upgrade yet, connect OAuth clients only to MCP servers you trust. 2.0.0 and 2.1.0 already accept expectedIssuer.

Direct calls to refreshAuthorization or exchangeAuthorization are outside this fix. So is 2.x with skipIssuerMetadataValidation set to true. Do not leave either case to the model to decide whether credentials are sent.

How is this different from the two earlier articles?

Why a schema is neededMCP securityThis article
AnswersWhy output needs a shapeWho has to hold the permission boundaryWhen issuers differ, which log step stops the tool call
Does not coverNot this OAuth advisory's stopNot the log order after the advisoryNot a replayable request, and not whether a schema is enough

How do you check this stop in JSONNote?

  1. 1
    Pick the right operation

    Open JSON Schema. Set Operation to Check with Schema. Paste the stop above in the first field and the schema in the second. step may only be credential_check. decision may only be allow, deny, or ask.

  2. 2
    Reject four bad documents

    Set decision to yes, set credentialsSent to the string false, delete decision, or set step to tool_call. Each of those fails. The deny record above passes. A pass only means the shape is right.

  3. 3
    Diff deny against the shape-valid allow

    Open JSON Compare Diff. Paste the deny stop in the first field. In the second, change only credentialsSent to true and decision to allow. reason stays issuer_mismatch. Both pass the schema. The app rule stops the send: when the issuers differ, the decision must be deny and nothing is sent.

Common questions

Does a stdio client need this log line?

No. The advisory says stdio clients and MCP servers built with this SDK are outside the affected range. The credential_check line is for an OAuth client over HTTP that may connect to an MCP server you do not fully trust while holding credentials for a legitimate authorization server.

Is upgrading to 1.31.0 or 2.2.0 enough?

No. Bundled providers need expectedIssuer. Credentials saved without issuer need that field or must be cleared. A new interactive sign-in is still completed only for servers you trust. Direct calls to refreshAuthorization or exchangeAuthorization, and skipIssuerMetadataValidation set to true, are outside this fix.

Once the issuers match, can the tool call run?

Not from that alone. Matching issuers only means the credentials were not sent to a different authorization server. The tool call still needs its own permission decision: is the tool allowlisted, and does this grant include the capability? That is the previous article. This article does not write that call after a deny.

Is this the same article as the MCP security one?

No. That one is about who has to hold the permission boundary. This one is about which JSON log step stops the tool call after the 30 September 2026 advisory. Both connect to the tools, but the cut is different.

Should the log store the refresh_token?

No. The stop keeps only the two issuer strings, whether they match, whether anything was sent, decision, and reason. Tokens and the client secret do not go in this JSON. If the client may have connected to an untrusted server, rotate the key and revoke the tokens instead of copying them into the log.

Summary

Credentials have to be bound to their original issuer first. When the MCP server names a different authorization server, the log stops at credential_check: deny, do not send, do not write a tool call. The version upgrade puts that comparison in the client. expectedIssuer and old stored credentials still need you to fill them in.

The decision: compare issuers first; if they differ, deny; keep credentialsSent false; do not append a tool call; set expectedIssuer on bundled providers; if the client may have connected to an untrusted server, rotate the key and revoke the tokens.

Next: pin this stop with the schema, then use Diff to see the deny record against the shape-valid record whose decision is allow.

Back to the blog