{"record":{"id":"845b9192956e5e1a","repo":"can1357/oh-my-pi","slug":"token-exchange-returned-no-access-token-providere","errorCode":null,"errorMessage":"Token exchange returned no access token${providerError ? `: ${providerError}` : \"\"}","messagePattern":"Token exchange returned no access token(.+?)` : \"\"\\}","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/mcp/oauth-flow.ts","lineNumber":533,"sourceCode":"\t\t\tconst errorText = await response.text();\n\t\t\tthrow new Error(`Token exchange failed: ${response.status} ${errorText}`);\n\t\t}\n\n\t\tconst data = (await response.json()) as {\n\t\t\taccess_token?: string;\n\t\t\trefresh_token?: string;\n\t\t\texpires_in?: number;\n\t\t\ttoken_type?: string;\n\t\t\terror?: string;\n\t\t\terror_description?: string;\n\t\t};\n\n\t\t// Some providers (e.g. the Slack Web API) signal failure with HTTP 200 and\n\t\t// an `{ ok: false, error }` body. Accepting such a response would store an\n\t\t// empty access token and only surface `invalid_token` on a later request.\n\t\tif (typeof data.access_token !== \"string\" || data.access_token.length === 0) {\n\t\t\tconst providerError = data.error_description ?? data.error;\n\t\t\tthrow new Error(`Token exchange returned no access token${providerError ? `: ${providerError}` : \"\"}`);\n\t\t}\n\n\t\t// Calculate expiry timestamp\n\t\tconst expiresIn = data.expires_in ?? 3600; // Default to 1 hour\n\t\tconst expires = Date.now() + expiresIn * 1000;\n\n\t\treturn {\n\t\t\taccess: data.access_token,\n\t\t\trefresh: data.refresh_token ?? \"\",\n\t\t\texpires,\n\t\t};\n\t}\n\n\t/**\n\t * Generate PKCE code verifier (random string).\n\t */\n\t#generateCodeVerifier(): string {\n\t\tconst bytes = new Uint8Array(32);","sourceCodeStart":515,"sourceCodeEnd":551,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/mcp/oauth-flow.ts#L515-L551","documentation":"Thrown by exchangeToken in the MCP OAuth flow when the token endpoint responds successfully but the JSON body has no non-empty `access_token`. This catches providers like the Slack Web API that return HTTP 200 with an `{ ok: false, error }` body instead of a real HTTP error, which would otherwise store an empty token that only fails later with `invalid_token`. The message appends the provider's `error_description` or `error` field when present.","triggerScenarios":"Calling exchangeToken after the OAuth code-for-token exchange when the provider replies 200 with a body missing `access_token`, an empty-string `access_token`, or a Slack-style `{ ok: false, error: ... }` payload.","commonSituations":"Misconfigured OAuth client (wrong client_secret so the provider returns an error object with 200), authorization code already used/expired, provider-specific non-standard OAuth implementations (Slack-style APIs), or the redirect URL not matching the registered callback.","solutions":["Read the provider error appended after the colon in the message (e.g. `bad_verification_code`, `invalid_client`) and fix the corresponding OAuth parameter","Verify client_id, client_secret, and redirect_uri exactly match the app registration in the provider's dashboard","Re-run the full OAuth flow to get a fresh authorization code — codes are single-use and expire quickly","Check the MCP server's OAuth documentation for non-standard token endpoint behavior"],"exampleFix":"// before: blindly trusting any 200 response\nconst data = await response.json();\nawait storeToken(data.access_token);\n// after: validate before storing (this is what the library does)\nif (typeof data.access_token !== \"string\" || data.access_token.length === 0) {\n  throw new Error(`Token exchange returned no access token${providerError ? `: ${providerError}` : \"\"}`);\n}","handlingStrategy":"validation","validationCode":"// validate before calling the OAuth flow\nconst clientId = config.clientId?.trim();\nconst clientSecret = config.clientSecret?.trim();\nif (!clientId || !clientSecret) throw new Error(\"OAuth client_id/client_secret required before token exchange\");","typeGuard":"function hasAccessToken(d: unknown): d is { access_token: string } {\n  return typeof d === \"object\" && d !== null && typeof (d as any).access_token === \"string\" && (d as any).access_token.length > 0;\n}","tryCatchPattern":"try {\n  const token = await exchangeToken(code, verifier, ...);\n} catch (err) {\n  const msg = err instanceof Error ? err.message : String(err);\n  if (msg.startsWith(\"Token exchange returned no access token\")) {\n    // surface msg tail (provider error) to user, prompt re-login\n  } else throw err;\n}","preventionTips":["Double-check client_id/client_secret/redirect_uri against the provider dashboard before the flow","Always run a fresh authorization code — codes expire and are single-use","Read the provider error tail in the message; it names the exact OAuth failure","Test OAuth flows against the specific MCP server's documented provider quirks"],"tags":["oauth","mcp","token-exchange"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}