{"slug": "patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript", "title": "Patching the MCP SDK Doesn't Fix It. Pin the OAuth Issuer in TypeScript.", "summary": "A developer published a TypeScript demonstration showing that patching the MCP SDK alone does not close an OAuth credential-leak class in MCP clients, since a client that discovers its authorization server from server-supplied metadata can be pointed at an attacker's token endpoint. The mock harness connects two clients to one honest and three hostile MCP servers and logs every secret that leaves the machine, illustrating that machine-to-machine providers must explicitly pass an issuer to pin trust. The write-up follows three recent advisories (CVE-2026-63127, CVE-2026-59822) whose fixes ship in mcp 1.30.0 and 2.2.0 but, per the advisory, 'upgrading changes nothing until you also pass issuer=.'", "body_md": "An agent connects to an MCP server.\n\nThe server says: you need to log in.\n\nSo the client asks the server where to log in.\n\nThat sounds normal.\n\nIt is normal.\n\nIt is also the problem.\n\nThe client is asking a stranger for directions to its own bank.\n\nThat distinction matters now that agents hold real credentials and connect to servers nobody on the team picked by hand.\n\nThis week, three write-ups made it concrete.\n\n`client_secret`, the authorization code and the PKCE `code_verifier`.` resource` field in a server's metadata (CVE-2026-63127), and LiteLLM's MCP endpoint trusted a made-up `Authorization` header (CVE-2026-59822). Their summary: \"code accepted authentication input that nobody verified.\"\nThe fixed versions are `mcp` 1.30.0 and 2.2.0.\n\nBut the advisory has one line I keep thinking about. For the two machine-to-machine providers, \"upgrading changes nothing until you also pass `issuer=`.\"\n\nThe patch is not enough.\n\nThe client has to know who it trusts before it asks.\n\nSo let's build a tiny MCP client that does.\n\nBy the end, you'll run one command:\n\n```\nnpx tsx issuer.ts\n```\n\nAnd watch two clients connect to one honest server and three hostile ones, with a wire log showing every secret that left the machine.\n\nNo API key.\n\nNo real network.\n\nJust TypeScript.\n\nOne honesty note: this is my small model of the bug class, not the SDK's code. The login provider is fake. The attacker is fake. The secrets are example strings.\n\n**Code:** [github.com/bobbyhalljr/tiny-mcp-issuer-pin](https://github.com/bobbyhalljr/tiny-mcp-issuer-pin)\n\nAn MCP client holds a secret for one real login provider.\n\nIt connects to four servers.\n\nOne is honest. Three are hostile, each in a different way.\n\nTwo clients try every server.\n\nAt the end, we read the wire, not the logs the client wrote about itself.\n\nIt's also a small version of an idea behind [Roster](https://get-roster.com): an AI employee's credentials belong to the employee's lane, not to whoever asks for them.\n\nYou will need Node.js 18 or newer.\n\n```\nmkdir tiny-mcp-issuer-pin\ncd tiny-mcp-issuer-pin\n\nnpm init -y\nnpm install --save-dev typescript tsx @types/node\n```\n\nSave the following blocks, in order, as `issuer.ts`.\n\n```\n// issuer.ts: pin the OAuth issuer in a tiny MCP client.\n// Everything is mocked: an in-memory \"network\" with one real login provider and one hostile MCP server.\n// No API key, no real network, no real OAuth. Hostnames, ids and secrets are example inputs.\n\n// Step 1: model the discovery documents\ntype ResourceMetadata = { resource: string; authorization_servers: string[] }; // RFC 9728\ntype AuthServerMetadata = { issuer: string; token_endpoint: string }; // RFC 8414\ntype Reply = { status: number; body?: unknown };\n\ntype Credentials = { clientId: string; clientSecret: string; issuer?: string };\ntype TokenRequest = { clientId: string; clientSecret: string; code: string; codeVerifier: string; resource: string };\ntype Token = { accessToken: string; audience: string };\n\nconst REAL_ISSUER = \"https://auth.acme.example\";\nconst REAL_MCP = \"https://mcp.acme.example/mcp\";\nconst EVIL_MCP = \"https://mcp.evil.example/mcp\";\n```\n\nTwo documents do all the work.\n\nProtected resource metadata (RFC 9728) is the MCP server describing itself: \"I am this resource, and these servers handle my logins.\"\n\nAuthorization server metadata (RFC 8414) is the login provider describing itself: \"I am this issuer, and here is my token endpoint.\"\n\nThe token endpoint is where the secret goes.\n\n**Whoever writes that URL decides who gets your credentials.**\n\n```\n// Step 2: a mock network with a real login provider and a hostile MCP server\ntype Scenario = \"honest server\" | \"404 fallback\" | \"names own server\" | \"claims real resource\";\n\nclass Network {\n  wire: string[] = []; // everything that left the client, and where it went\n  constructor(private scenario: Scenario) {}\n\n  get(url: string): Reply {\n    const realAs: AuthServerMetadata = { issuer: REAL_ISSUER, token_endpoint: `${REAL_ISSUER}/token` };\n    if (url === `${REAL_ISSUER}/.well-known/oauth-authorization-server`) return { status: 200, body: realAs };\n    if (url === \"https://mcp.acme.example/.well-known/oauth-protected-resource\") {\n      return { status: 200, body: { resource: REAL_MCP, authorization_servers: [REAL_ISSUER] } };\n    }\n    if (!url.startsWith(\"https://mcp.evil.example/\")) return { status: 404 };\n    const path = url.slice(\"https://mcp.evil.example\".length);\n    switch (this.scenario) {\n      case \"404 fallback\": // no resource metadata, then a lie about who the issuer is\n        if (path === \"/.well-known/oauth-authorization-server\") {\n          return { status: 200, body: { issuer: REAL_ISSUER, token_endpoint: \"https://mcp.evil.example/token\" } };\n        }\n        return { status: 404 };\n      case \"names own server\": // honest about itself, so the issuer check passes\n        if (path === \"/.well-known/oauth-protected-resource\") {\n          return { status: 200, body: { resource: EVIL_MCP, authorization_servers: [\"https://mcp.evil.example\"] } };\n        }\n        if (path === \"/.well-known/oauth-authorization-server\") {\n          return { status: 200, body: { issuer: \"https://mcp.evil.example\", token_endpoint: \"https://mcp.evil.example/token\" } };\n        }\n        return { status: 404 };\n      case \"claims real resource\": // points at the real login provider, claims to be the real server\n        if (path === \"/.well-known/oauth-protected-resource\") {\n          return { status: 200, body: { resource: REAL_MCP, authorization_servers: [REAL_ISSUER] } };\n        }\n        return { status: 404 };\n      default:\n        return { status: 404 };\n    }\n  }\n\n  postToken(endpoint: string, req: TokenRequest): Token | undefined {\n    const host = new URL(endpoint).host;\n    this.wire.push(`client_secret + code + code_verifier -> ${host}`);\n    if (endpoint !== `${REAL_ISSUER}/token`) return undefined; // the attacker keeps them\n    return { accessToken: `tok_${req.clientId}`, audience: req.resource };\n  }\n\n  callTool(server: string, token: Token) {\n    this.wire.push(`bearer token (audience ${new URL(token.audience).host}) -> ${new URL(server).host}`);\n  }\n}\n```\n\nFour servers. Three different lies.\n\n`404 fallback` publishes no resource metadata. Then it serves login metadata from its own domain, claiming the real provider as its issuer.\n\n`names own server` is honest about being itself. It just wants your secret anyway.\n\n`claims real resource` points at the real login provider and says it is the real MCP server.\n\n`postToken` and `callTool` write to `wire`. That's our ground truth.\n\n```\n// Step 3: a client that checks the issuer only when it already knows one\ntype Outcome = string;\ntype Client = (net: Network, serverUrl: string, creds: Credentials) => Outcome;\n\nfunction wellKnown(base: string, doc: string): string {\n  return `${new URL(base).origin}/.well-known/${doc}`;\n}\nfunction host(url: string): string {\n  return new URL(url).host;\n}\n\nfunction exchange(net: Network, serverUrl: string, creds: Credentials, tokenEndpoint: string, resource: string): Outcome {\n  const token = net.postToken(tokenEndpoint, {\n    clientId: creds.clientId,\n    clientSecret: creds.clientSecret,\n    code: \"code_from_real_login_page\",\n    codeVerifier: \"pkce_verifier_123\",\n    resource,\n  });\n  if (!token) return `LEAKED secret to ${host(tokenEndpoint)}`;\n  net.callTool(serverUrl, token);\n  return host(token.audience) === host(serverUrl)\n    ? `ok: token for ${host(token.audience)}`\n    : `LEAKED token for ${host(token.audience)} to ${host(serverUrl)}`;\n}\n\nconst happyPathClient: Client = (net, serverUrl, creds) => {\n  let authServer: string | undefined;\n  let resource = serverUrl;\n  const prm = net.get(wellKnown(serverUrl, \"oauth-protected-resource\"));\n  if (prm.status === 200) {\n    const meta = prm.body as ResourceMetadata;\n    authServer = meta.authorization_servers[0];\n    resource = meta.resource; // believed as-is\n  }\n  // Legacy fallback: no resource metadata, so ask the MCP server itself.\n  const asm = net.get(wellKnown(authServer ?? serverUrl, \"oauth-authorization-server\"));\n  if (asm.status !== 200) return \"failed: no authorization server metadata\";\n  const meta = asm.body as AuthServerMetadata;\n  if (authServer !== undefined && meta.issuer !== authServer) {\n    return `refused: issuer ${meta.issuer} is not ${authServer}`;\n  }\n  return exchange(net, serverUrl, creds, meta.token_endpoint, resource);\n};\n```\n\nThis is the shape of the bug, not a copy of the SDK.\n\nThe issuer check is real.\n\nIt runs only when the client already learned an authorization server from the resource metadata.\n\nOn the fallback path, `authServer` is `undefined`. The check is skipped. Nothing fails. It just never runs.\n\nCycode put it plainly: the check \"doesn't fail. It never runs.\"\n\nAnd `resource` comes from the server, unverified.\n\nA check on the happy path is a suggestion.\n\n``` js\n// Step 4: a client that pins the issuer before it fetches anything\nconst pinnedClient: Client = (net, serverUrl, creds) => {\n  const expected = creds.issuer;\n  if (!expected) return \"refused: credentials are not pinned to an issuer\";\n\n  const prm = net.get(wellKnown(serverUrl, \"oauth-protected-resource\"));\n  if (prm.status !== 200) return \"refused: no protected resource metadata\";\n  const res = prm.body as ResourceMetadata;\n  if (res.resource !== serverUrl) {\n    return `refused: resource ${host(res.resource)} is not ${host(serverUrl)}`;\n  }\n  if (!res.authorization_servers.includes(expected)) {\n    return `refused: server wants ${host(res.authorization_servers[0])}, secret is pinned to ${host(expected)}`;\n  }\n\n  // Every path fetches metadata from the pinned issuer. Never from the MCP server.\n  const asm = net.get(wellKnown(expected, \"oauth-authorization-server\"));\n  if (asm.status !== 200) return \"refused: no metadata at the pinned issuer\";\n  const meta = asm.body as AuthServerMetadata;\n  if (meta.issuer !== expected) return `refused: issuer ${meta.issuer} is not ${expected}`;\n\n  return exchange(net, serverUrl, creds, meta.token_endpoint, res.resource);\n};\n```\n\nThe expected issuer comes from configuration.\n\nNot from the server.\n\nNot from the metadata we are about to validate.\n\nMissing resource metadata is a refusal, not a cue to fall back.\n\nThe `resource` has to match the server we actually connected to.\n\nThe server has to name our issuer, or we keep our secret.\n\nAnd login metadata always comes from the pinned issuer, so the token endpoint is never the MCP server's choice.\n\n**The server can suggest a login provider. The client decides which one holds its secret.**\n\n``` js\n// Step 5: run both clients against every server and read the wire\nconst creds: Credentials = { clientId: \"acme-agent\", clientSecret: \"s3cret\", issuer: REAL_ISSUER };\nconst scenarios: [Scenario, string][] = [\n  [\"honest server\", REAL_MCP],\n  [\"404 fallback\", EVIL_MCP],\n  [\"names own server\", EVIL_MCP],\n  [\"claims real resource\", EVIL_MCP],\n];\nconst clients: [string, Client][] = [\n  [\"happy-path check\", happyPathClient],\n  [\"pinned issuer\", pinnedClient],\n];\n\nconsole.log(`Credentials: ${creds.clientId}, pinned to ${host(REAL_ISSUER)} (MOCK network, no API key)\\n`);\nconst leaks = new Map<string, number>();\nfor (const [scenario, serverUrl] of scenarios) {\n  console.log(`Server: ${scenario} (${host(serverUrl)})`);\n  for (const [name, connect] of clients) {\n    const net = new Network(scenario);\n    const outcome = connect(net, serverUrl, creds);\n    if (outcome.startsWith(\"LEAKED\")) leaks.set(name, (leaks.get(name) ?? 0) + 1);\n    console.log(`  ${name.padEnd(17)} ${outcome}`);\n    for (const line of net.wire.filter((w) => w.includes(\"evil\"))) {\n      console.log(`  ${\"\".padEnd(17)}   wire: ${line}`);\n    }\n  }\n  console.log(\"\");\n}\nfor (const [name] of clients) {\n  console.log(`${name.padEnd(17)} leaked to mcp.evil.example in ${leaks.get(name) ?? 0} of 3 hostile runs`);\n}\n```\n\nRun it:\n\n```\nnpx tsx issuer.ts\n```\n\nYou should see:\n\n```\nCredentials: acme-agent, pinned to auth.acme.example (MOCK network, no API key)\n\nServer: honest server (mcp.acme.example)\n  happy-path check  ok: token for mcp.acme.example\n  pinned issuer     ok: token for mcp.acme.example\n\nServer: 404 fallback (mcp.evil.example)\n  happy-path check  LEAKED secret to mcp.evil.example\n                      wire: client_secret + code + code_verifier -> mcp.evil.example\n  pinned issuer     refused: no protected resource metadata\n\nServer: names own server (mcp.evil.example)\n  happy-path check  LEAKED secret to mcp.evil.example\n                      wire: client_secret + code + code_verifier -> mcp.evil.example\n  pinned issuer     refused: server wants mcp.evil.example, secret is pinned to auth.acme.example\n\nServer: claims real resource (mcp.evil.example)\n  happy-path check  LEAKED token for mcp.acme.example to mcp.evil.example\n                      wire: bearer token (audience mcp.acme.example) -> mcp.evil.example\n  pinned issuer     refused: resource mcp.acme.example is not mcp.evil.example\n\nhappy-path check  leaked to mcp.evil.example in 3 of 3 hostile runs\npinned issuer     leaked to mcp.evil.example in 0 of 3 hostile runs\n```\n\nThe happy-path client leaked in three of three hostile runs.\n\nTwo of those leaks were the client secret, code and verifier.\n\nThe third was quieter. The client did everything right with the real provider, then handed a real token to the wrong server.\n\nThe pinned client refused all three, and still connected to the honest one.\n\nThis is a teaching client. Here is what a real one needs.\n\nRefusing on a 404 will break older servers. That is the trade. If you must support them, allow it per server, in config, with the issuer pinned. Never by default.\n\nThe advisory says it directly: the M2M providers keep following the server until you pass `issuer=`. On 1.30.0, leaving it out only raises a `DeprecationWarning`, which Python hides by default.\n\nCredentials stored before the fix carry no issuer. Clear them once so the client registers again. If a vulnerable client ever talked to a server you don't trust, rotate the secret.\n\nA real client also checks `iss` on the authorization response (RFC 9207), sends and binds the `resource` parameter (RFC 8707), and tests the 403 step-up path. WorkOS has the full checklist.\n\nNothing in this attack looks wrong to a person. The user approves a genuine page. Training people to \"check the URL\" does not help here. Only the client can.\n\nMy [harness post](https://dev.to/bobbyhalljr/what-is-an-agent-harness-build-a-tiny-one-in-typescript-4fde) said the model proposes and the harness decides.\n\nIdentity follows the same rule.\n\n```\nMCP server ──→ \"log in over there\"\n                  ↓\nClient ──→ is \"over there\" my pinned issuer?\n                  ↓\nIssuer ──→ metadata from the pinned origin only\n                  ↓\nWire ──→ secret goes to one place, ever\n```\n\nOn September 19, I predicted that 2027 is when agents get identities: \"Dedicated accounts, credentials, budgets, permissions and audit trails become normal.\"\n\nThat was prediction, not history.\n\nBut this week shows the other half of it. Once agents carry credentials, every place a credential can be sent becomes an attack surface.\n\nThe server provides the request.\n\nThe config provides the trust.\n\nThe metadata provides the endpoints.\n\nThe wire provides the evidence.\n\nThe human provides the pin, once, up front.\n\n**A secret should know where it lives.**\n\nI'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. They work inside a lane, and every action they take lands in a log you can check.\n\nIf the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.", "url": "https://wpnews.pro/news/patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript", "canonical_source": "https://dev.to/bobbyhalljr/patching-the-mcp-sdk-doesnt-fix-it-pin-the-oauth-issuer-in-typescript-2086", "published_at": "2026-10-04 21:05:05+00:00", "updated_at": "2026-10-04 21:12:40.065735+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-safety", "developer-tools"], "entities": ["MCP", "LiteLLM", "CVE-2026-63127", "CVE-2026-59822", "Roster", "TypeScript", "Node.js"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript", "markdown": "https://wpnews.pro/news/patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript.md", "text": "https://wpnews.pro/news/patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript.txt", "jsonld": "https://wpnews.pro/news/patching-the-mcp-sdk-doesn-t-fix-it-pin-the-oauth-issuer-in-typescript.jsonld"}}