From b452de2053a314e387481ef253df16ce3355cd12 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Fri, 6 Feb 2026 13:23:19 +0000 Subject: [PATCH 01/10] feat(bugsnag): automate oauth flow on startup if unauthenticated Remove manual login tools and automatically start device flow and polling when no auth token is found. --- src/bugsnag/client.ts | 124 +++++++++++++++++++++++++++++++++----- src/bugsnag/oauth.ts | 88 +++++++++++++++++++++++++++ src/common/token-store.ts | 67 ++++++++++++++++++++ 3 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 src/bugsnag/oauth.ts create mode 100644 src/common/token-store.ts diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 029df8d8..2133f899 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { CacheService } from "../common/cache"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info"; import type { SmartBearMcpServer } from "../common/server"; +import { TokenStore } from "../common/token-store"; import { ToolError } from "../common/tools"; import type { Client, @@ -9,21 +10,22 @@ import type { RegisterResourceFunction, RegisterToolsFunction, } from "../common/types"; -import { - type Build, - Configuration, - CurrentUserAPI, - ErrorAPI, - ErrorUpdateRequest, - type EventField, - type Organization, - type Project, - ProjectAPI, - type Release, - type TraceField, +import { ErrorUpdateRequest } from "./client/api/api"; +import { CurrentUserAPI } from "./client/api/CurrentUser"; +import { Configuration } from "./client/api/configuration"; +import { ErrorAPI } from "./client/api/Error"; +import type { + Build, + EventField, + Organization, + Project, + Release, + TraceField, } from "./client/api/index"; +import { ProjectAPI } from "./client/api/Project"; import { type FilterObject, toUrlSearchParams } from "./client/filters"; import { toolInputParameters } from "./input-schemas"; +import { OAuthService } from "./oauth"; const HUB_PREFIX = "00000"; const DEFAULT_DOMAIN = "bugsnag.com"; @@ -62,9 +64,12 @@ interface StabilityData { } const ConfigurationSchema = z.object({ - auth_token: z.string().describe("BugSnag personal authentication token"), + auth_token: z + .string() + .describe("BugSnag personal authentication token") + .optional(), project_api_key: z.string().describe("BugSnag project API key").optional(), - endpoint: z.url().describe("BugSnag endpoint URL").optional(), + endpoint: z.string().url().describe("BugSnag endpoint URL").optional(), }); export class BugsnagClient implements Client { @@ -76,6 +81,7 @@ export class BugsnagClient implements Client { private _errorsApi: ErrorAPI | undefined; private _projectApi: ProjectAPI | undefined; private _appEndpoint: string | undefined; + private _config: z.infer | undefined; get currentUserApi(): CurrentUserAPI { if (!this._currentUserApi) throw new Error("Client not configured"); @@ -106,14 +112,101 @@ export class BugsnagClient implements Client { server: SmartBearMcpServer, config: z.infer, ): Promise { + this._config = config; this.cache = server.getCache(); this._appEndpoint = this.getEndpoint( "app", config.project_api_key, config.endpoint, ); + + let authToken = config.auth_token; + + if (!authToken) { + // Try to load from store + const tokenData = await TokenStore.load("bugsnag"); + if (tokenData && tokenData.accessToken) { + // Check expiry if needed, but for now just use it + authToken = tokenData.accessToken; + } + } + + if (!authToken) { + console.error( + "No authentication token provided for BugSnag. Starting automated authentication flow...", + ); + this.authenticate(); + // We still mark as configured to allow tools to be registered, but they will fail until auth completes + this._isConfigured = true; + return; + } + + await this.initializeApis(authToken, config); + } + + private async authenticate() { + const authority = + process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:8080"; + const clientId = process.env.BUGSNAG_OAUTH_CLIENT_ID || "mcp-client"; + + try { + const response = await OAuthService.startDeviceAuth( + authority, + clientId, + "api", + ); + + console.error(`\n\n[BugSnag] Authentication Required`); + console.error(`Please visit: ${response.verification_uri}`); + console.error(`And enter code: ${response.user_code}`); + console.error(`\nWaiting for approval...`); + + // Polling loop + const intervalMs = (response.interval || 5) * 1000; + let accessToken: string | undefined; + + while (!accessToken) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + try { + const tokenParams = await OAuthService.pollToken( + authority, + clientId, + response.device_code, + ); + accessToken = tokenParams.access_token; + + // Save and init + await TokenStore.save("bugsnag", { + accessToken, + expiresAt: Math.floor(Date.now() / 1000) + tokenParams.expires_in, + }); + + if (this._config) { + await this.initializeApis(accessToken, this._config); + } + console.error("\n[BugSnag] Successfully authenticated!\n"); + } catch (e: any) { + if ( + e.message !== "authorization_pending" && + e.message !== "slow_down" + ) { + console.error("[BugSnag] Authentication failed:", e.message); + break; // Stop polling on fatal error + } + // If slow_down, maybe increase interval? For now stick to default. + } + } + } catch (e) { + console.error("Failed to start authentication flow:", e); + } + } + + private async initializeApis( + authToken: string, + config: z.infer, + ) { const apiConfig = new Configuration({ - apiKey: `token ${config.auth_token}`, + apiKey: `token ${authToken}`, headers: { "User-Agent": `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`, "Content-Type": "application/json", @@ -161,7 +254,6 @@ export class BugsnagClient implements Client { return; } this._isConfigured = true; - return; } isConfigured(): boolean { diff --git a/src/bugsnag/oauth.ts b/src/bugsnag/oauth.ts new file mode 100644 index 00000000..200bd31b --- /dev/null +++ b/src/bugsnag/oauth.ts @@ -0,0 +1,88 @@ +import { ToolError } from "../common/tools"; + +export interface DeviceAuthResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval: number; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +export class OAuthService { + static async startDeviceAuth( + authorityUrl: string, + clientId: string, + scope: string, + ): Promise { + const params = new URLSearchParams({ + client_id: clientId, + scope: scope, + }); + + const response = await fetch(`${authorityUrl}/device/authorize`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!response.ok) { + throw new ToolError( + `Failed to start device auth: ${response.status} ${response.statusText}`, + ); + } + + return (await response.json()) as DeviceAuthResponse; + } + + static async pollToken( + authorityUrl: string, + clientId: string, + deviceCode: string, + ): Promise { + const params = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: clientId, + device_code: deviceCode, + }); + + const response = await fetch(`${authorityUrl}/device/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.error === "authorization_pending") { + throw new ToolError("authorization_pending"); + } + if (data.error === "slow_down") { + throw new ToolError("slow_down"); + } + if (data.error === "expired_token") { + throw new ToolError("expired_token"); + } + if (data.error === "access_denied") { + throw new ToolError("access_denied"); + } + + throw new ToolError( + `Failed to poll token: ${response.status} ${response.statusText} - ${JSON.stringify(data)}`, + ); + } + + return data as TokenResponse; + } +} diff --git a/src/common/token-store.ts b/src/common/token-store.ts new file mode 100644 index 00000000..500af8de --- /dev/null +++ b/src/common/token-store.ts @@ -0,0 +1,67 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +export interface TokenData { + accessToken: string; + expiresAt: number; // Unix timestamp in seconds + refreshToken?: string; +} + +export class TokenStore { + private static getFilePath(): string { + const homeDir = os.homedir(); + return path.join(homeDir, ".smartbear", "tokens.json"); + } + + private static async ensureDir(): Promise { + const filePath = TokenStore.getFilePath(); + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + } + + static async save(service: string, data: TokenData): Promise { + await TokenStore.ensureDir(); + const filePath = TokenStore.getFilePath(); + let tokens: Record = {}; + + try { + const content = await fs.readFile(filePath, "utf-8"); + tokens = JSON.parse(content); + } catch (error) { + // Ignore error if file doesn't exist or is invalid + } + + tokens[service] = data; + await fs.writeFile(filePath, JSON.stringify(tokens, null, 2), { + mode: 0o600, // Secure permissions + }); + } + + static async load(service: string): Promise { + const filePath = TokenStore.getFilePath(); + try { + const content = await fs.readFile(filePath, "utf-8"); + const tokens = JSON.parse(content); + return tokens[service] || null; + } catch (error) { + return null; + } + } + + static async clear(service: string): Promise { + const filePath = TokenStore.getFilePath(); + try { + const content = await fs.readFile(filePath, "utf-8"); + const tokens = JSON.parse(content); + if (tokens[service]) { + delete tokens[service]; + await fs.writeFile(filePath, JSON.stringify(tokens, null, 2), { + mode: 0o600, + }); + } + } catch (error) { + // Ignore error + } + } +} From dab67b7e5f9a183e211ca7c87b998ba4cd449a35 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Mon, 9 Feb 2026 09:54:45 +0000 Subject: [PATCH 02/10] feat: oauth login --- src/bugsnag/client.ts | 3 ++- src/bugsnag/oauth.ts | 18 ++++++++++++------ src/common/token-store.ts | 1 + 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 2133f899..739038b5 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -131,6 +131,7 @@ export class BugsnagClient implements Client { } } + console.log("authToken after checking store:", authToken); if (!authToken) { console.error( "No authentication token provided for BugSnag. Starting automated authentication flow...", @@ -146,7 +147,7 @@ export class BugsnagClient implements Client { private async authenticate() { const authority = - process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:8080"; + process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:7070"; const clientId = process.env.BUGSNAG_OAUTH_CLIENT_ID || "mcp-client"; try { diff --git a/src/bugsnag/oauth.ts b/src/bugsnag/oauth.ts index 200bd31b..2454796a 100644 --- a/src/bugsnag/oauth.ts +++ b/src/bugsnag/oauth.ts @@ -21,17 +21,23 @@ export class OAuthService { clientId: string, scope: string, ): Promise { - const params = new URLSearchParams({ + const params = JSON.stringify({ client_id: clientId, scope: scope, }); + console.log( + "Starting device auth with url:", + `${authorityUrl}/device/authorize`, + "and params:", + params, + ); const response = await fetch(`${authorityUrl}/device/authorize`, { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", }, - body: params.toString(), + body: params, }); if (!response.ok) { @@ -48,7 +54,7 @@ export class OAuthService { clientId: string, deviceCode: string, ): Promise { - const params = new URLSearchParams({ + const params = JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", client_id: clientId, device_code: deviceCode, @@ -57,9 +63,9 @@ export class OAuthService { const response = await fetch(`${authorityUrl}/device/token`, { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", }, - body: params.toString(), + body: params, }); const data = await response.json(); diff --git a/src/common/token-store.ts b/src/common/token-store.ts index 500af8de..ea7efe40 100644 --- a/src/common/token-store.ts +++ b/src/common/token-store.ts @@ -21,6 +21,7 @@ export class TokenStore { } static async save(service: string, data: TokenData): Promise { + console.log(`Saving token for service: ${service}`); await TokenStore.ensureDir(); const filePath = TokenStore.getFilePath(); let tokens: Record = {}; From 1c33a91d2599dc99c07f10d8762b7dc81f47bc69 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Mon, 9 Feb 2026 10:47:42 +0000 Subject: [PATCH 03/10] feat: more oauth flow --- scripts/mock-oauth-server.cjs | 170 +++++++++++++++++++++++++ scripts/test-auth-flow.ts | 68 ++++++++++ src/bugsnag/client.ts | 60 +++------ src/bugsnag/oauth.ts | 229 +++++++++++++++++++++++----------- 4 files changed, 412 insertions(+), 115 deletions(-) create mode 100644 scripts/mock-oauth-server.cjs create mode 100644 scripts/test-auth-flow.ts diff --git a/scripts/mock-oauth-server.cjs b/scripts/mock-oauth-server.cjs new file mode 100644 index 00000000..8cff1a01 --- /dev/null +++ b/scripts/mock-oauth-server.cjs @@ -0,0 +1,170 @@ +const http = require("http"); +const crypto = require("crypto"); +const url = require("url"); + +const PORT = 3001; +const AUTH_CODES = new Map(); // Store codes and their associated challenges + +// Helper to calculate S256 challenge from verifier +function calculateS256(verifier) { + const hash = crypto.createHash("sha256").update(verifier).digest(); + return hash + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +const server = http.createServer((req, res) => { + const reqUrl = new URL(req.url, `http://localhost:${PORT}`); + console.log(`[MockServer] ${req.method} ${reqUrl.pathname}`); + + // CORS headers + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET" && reqUrl.pathname === "/authorize") { + const clientId = reqUrl.searchParams.get("client_id"); + const redirectUri = reqUrl.searchParams.get("redirect_uri"); + const responseType = reqUrl.searchParams.get("response_type"); + const codeChallenge = reqUrl.searchParams.get("code_challenge"); + const codeChallengeMethod = reqUrl.searchParams.get( + "code_challenge_method", + ); + const state = reqUrl.searchParams.get("state"); + + if (responseType !== "code") { + res.writeHead(400); + res.end("Invalid response_type"); + return; + } + + if (codeChallengeMethod !== "S256") { + res.writeHead(400); + res.end("Invalid code_challenge_method, expected S256"); + return; + } + + if (!codeChallenge) { + res.writeHead(400); + res.end("Missing code_challenge"); + return; + } + + // Generate a mock auth code + const code = crypto.randomBytes(16).toString("hex"); + AUTH_CODES.set(code, { + challenge: codeChallenge, + clientId, + redirectUri, + }); + + console.log( + `[MockServer] Generated code: ${code} for challenge: ${codeChallenge}`, + ); + + // Redirect back to the client's redirect_uri + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.append("code", code); + if (state) { + callbackUrl.searchParams.append("state", state); + } + + res.writeHead(302, { Location: callbackUrl.toString() }); + res.end(); + return; + } + + if (req.method === "POST" && reqUrl.pathname === "/token") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + + req.on("end", () => { + try { + let params; + if (req.headers["content-type"] === "application/json") { + params = JSON.parse(body); + } else { + // Fallback or error if strict + params = Object.fromEntries(new URLSearchParams(body)); + } + + const { grant_type, code, client_id, redirect_uri, code_verifier } = + params; + + console.log("[MockServer] Token request params:", params); + + if (grant_type !== "authorization_code") { + res.writeHead(400); + res.end(JSON.stringify({ error: "invalid_grant" })); + return; + } + + const authData = AUTH_CODES.get(code); + if (!authData) { + res.writeHead(400); + res.end(JSON.stringify({ error: "invalid_code" })); + return; + } + + if (authData.clientId !== client_id) { + res.writeHead(400); + res.end(JSON.stringify({ error: "invalid_client" })); + return; + } + + // Verify PKCE + const calculatedChallenge = calculateS256(code_verifier); + console.log( + `[MockServer] Verifying PKCE. \nStored Challenge: ${authData.challenge}\nCalculated from Verifier: ${calculatedChallenge}`, + ); + + if (calculatedChallenge !== authData.challenge) { + res.writeHead(400); + res.end( + JSON.stringify({ + error: "invalid_grant", + error_description: "PKCE verification failed", + }), + ); + return; + } + + // Success! + const tokenResponse = { + access_token: + "mock-access-token-" + crypto.randomBytes(8).toString("hex"), + token_type: "Bearer", + expires_in: 3600, + refresh_token: + "mock-refresh-token-" + crypto.randomBytes(8).toString("hex"), + scope: "api", + }; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(tokenResponse)); + } catch (err) { + console.error("[MockServer] Error processing token request:", err); + res.writeHead(500); + res.end("Internal Server Error"); + } + }); + return; + } + + res.writeHead(404); + res.end("Not Found"); +}); + +server.listen(PORT, () => { + console.log(`Mock OAuth Server running at http://localhost:${PORT}`); +}); diff --git a/scripts/test-auth-flow.ts b/scripts/test-auth-flow.ts new file mode 100644 index 00000000..68f92867 --- /dev/null +++ b/scripts/test-auth-flow.ts @@ -0,0 +1,68 @@ +import { spawn } from "child_process"; +import { OAuthService } from "../src/bugsnag/oauth"; + +const MOCK_SERVER_PORT = 3001; +const CLIENT_PORT = 8999; +const AUTHORITY_URL = `http://localhost:${MOCK_SERVER_PORT}`; +const REDIRECT_URI = `http://localhost:${CLIENT_PORT}/callback`; + +async function runTest() { + console.log("Starting Mock OAuth Server..."); + const mockServer = spawn("node", ["scripts/mock-oauth-server.cjs"], { + stdio: "inherit", + }); + + // Wait for mock server to be ready + await new Promise((resolve) => setTimeout(resolve, 1000)); + + console.log("Starting Auth Flow..."); + try { + const tokenPromise = OAuthService.startAuthCodeFlow( + AUTHORITY_URL, + "test-client-id", + REDIRECT_URI, + "api", + async (url) => { + console.log(`[Test] Received auth URL: ${url}`); + console.log("[Test] Simulating browser visit..."); + + // Simulate visiting the URL + // We need to fetch it and follow redirects + try { + const res = await fetch(url); + console.log(`[Test] Browser visited URL, status: ${res.status}`); + const text = await res.text(); + if (text.includes("Authorization Successful")) { + console.log("[Test] Browser saw success message."); + } else { + console.error("[Test] Browser did NOT see success message:", text); + } + } catch (e) { + console.error("[Test] Error visiting URL:", e); + } + }, + ); + + const token = await tokenPromise; + console.log("Auth Flow Complete!"); + console.log("Received Token:", token); + + if ( + token.access_token && + token.access_token.startsWith("mock-access-token") + ) { + console.log("SUCCESS: Token looks valid."); + } else { + console.error("FAILURE: Token is invalid."); + process.exit(1); + } + } catch (error) { + console.error("Auth Flow Failed:", error); + process.exit(1); + } finally { + mockServer.kill(); + process.exit(0); + } +} + +runTest(); diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 739038b5..917bf1a2 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -147,58 +147,34 @@ export class BugsnagClient implements Client { private async authenticate() { const authority = - process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:7070"; + process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:8080"; const clientId = process.env.BUGSNAG_OAUTH_CLIENT_ID || "mcp-client"; + const redirectUri = + process.env.BUGSNAG_REDIRECT_URI || "http://localhost:8080/callback"; try { - const response = await OAuthService.startDeviceAuth( + console.error(`\n\n[BugSnag] Authentication Required`); + console.error(`Starting Authorization Code Flow with PKCE...`); + + const tokenParams = await OAuthService.startAuthCodeFlow( authority, clientId, + redirectUri, "api", ); - console.error(`\n\n[BugSnag] Authentication Required`); - console.error(`Please visit: ${response.verification_uri}`); - console.error(`And enter code: ${response.user_code}`); - console.error(`\nWaiting for approval...`); - - // Polling loop - const intervalMs = (response.interval || 5) * 1000; - let accessToken: string | undefined; - - while (!accessToken) { - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - try { - const tokenParams = await OAuthService.pollToken( - authority, - clientId, - response.device_code, - ); - accessToken = tokenParams.access_token; - - // Save and init - await TokenStore.save("bugsnag", { - accessToken, - expiresAt: Math.floor(Date.now() / 1000) + tokenParams.expires_in, - }); + // Save and init + await TokenStore.save("bugsnag", { + accessToken: tokenParams.access_token, + expiresAt: Math.floor(Date.now() / 1000) + tokenParams.expires_in, + }); - if (this._config) { - await this.initializeApis(accessToken, this._config); - } - console.error("\n[BugSnag] Successfully authenticated!\n"); - } catch (e: any) { - if ( - e.message !== "authorization_pending" && - e.message !== "slow_down" - ) { - console.error("[BugSnag] Authentication failed:", e.message); - break; // Stop polling on fatal error - } - // If slow_down, maybe increase interval? For now stick to default. - } + if (this._config) { + await this.initializeApis(tokenParams.access_token, this._config); } - } catch (e) { - console.error("Failed to start authentication flow:", e); + console.error("\n[BugSnag] Successfully authenticated!\n"); + } catch (e: any) { + console.error("[BugSnag] Authentication failed:", e.message); } } diff --git a/src/bugsnag/oauth.ts b/src/bugsnag/oauth.ts index 2454796a..24232c3c 100644 --- a/src/bugsnag/oauth.ts +++ b/src/bugsnag/oauth.ts @@ -1,94 +1,177 @@ +import { spawn } from "child_process"; +import { createHash, randomBytes } from "crypto"; +import * as http from "http"; +import { URL } from "url"; import { ToolError } from "../common/tools"; -export interface DeviceAuthResponse { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete?: string; - expires_in: number; - interval: number; -} - export interface TokenResponse { access_token: string; token_type: string; expires_in: number; + refresh_token?: string; + scope?: string; } export class OAuthService { - static async startDeviceAuth( - authorityUrl: string, - clientId: string, - scope: string, - ): Promise { - const params = JSON.stringify({ - client_id: clientId, - scope: scope, - }); + private static base64URLEncode(buffer: Buffer): string { + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + } - console.log( - "Starting device auth with url:", - `${authorityUrl}/device/authorize`, - "and params:", - params, - ); - const response = await fetch(`${authorityUrl}/device/authorize`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: params, - }); + private static sha256(str: string): Buffer { + return createHash("sha256").update(str).digest(); + } + + private static generateVerifier(): string { + return OAuthService.base64URLEncode(randomBytes(32)); + } - if (!response.ok) { - throw new ToolError( - `Failed to start device auth: ${response.status} ${response.statusText}`, - ); - } + private static generateChallenge(verifier: string): string { + return OAuthService.base64URLEncode(OAuthService.sha256(verifier)); + } - return (await response.json()) as DeviceAuthResponse; + private static openBrowser(url: string) { + const start = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + spawn(start, [url], { detached: true, stdio: "ignore" }).unref(); } - static async pollToken( + static async startAuthCodeFlow( authorityUrl: string, clientId: string, - deviceCode: string, + redirectUri: string, + scope = "api", + onOpenUrl?: (url: string) => void, ): Promise { - const params = JSON.stringify({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: clientId, - device_code: deviceCode, - }); + const verifier = OAuthService.generateVerifier(); + const challenge = OAuthService.generateChallenge(verifier); + const state = OAuthService.base64URLEncode(randomBytes(16)); + const parsedRedirect = new URL(redirectUri); + const port = parseInt(parsedRedirect.port) || 80; - const response = await fetch(`${authorityUrl}/device/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: params, - }); + // 1. Construct Authorization URL + const authUrl = new URL(`${authorityUrl}/authorize`); + authUrl.searchParams.append("response_type", "code"); + authUrl.searchParams.append("client_id", clientId); + authUrl.searchParams.append("redirect_uri", redirectUri); + authUrl.searchParams.append("scope", scope); + authUrl.searchParams.append("state", state); + authUrl.searchParams.append("code_challenge", challenge); + authUrl.searchParams.append("code_challenge_method", "S256"); + // Required by RFC 8707 Resource Indicators for OAuth 2.0 + // We assume the resource is the authority for now or derived from it, + // but the prompt implies we are authorizing *for* BugSnag. + // Let's assume the resource ID matches the authority or is configured. + // For now, we'll omit explicit resource param unless strictly needed, + // as some providers choke on it if not configured. + // But spec says "MUST include resource parameter". + // I'll add it if it's distinct, but for BugSnag likely it's the API base. + // Let's stick to core PKCE first. + + return new Promise((resolve, reject) => { + let server: http.Server; - const data = await response.json(); - - if (!response.ok) { - if (data.error === "authorization_pending") { - throw new ToolError("authorization_pending"); - } - if (data.error === "slow_down") { - throw new ToolError("slow_down"); - } - if (data.error === "expired_token") { - throw new ToolError("expired_token"); - } - if (data.error === "access_denied") { - throw new ToolError("access_denied"); - } - - throw new ToolError( - `Failed to poll token: ${response.status} ${response.statusText} - ${JSON.stringify(data)}`, - ); - } - - return data as TokenResponse; + const cleanup = () => { + if (server) server.close(); + }; + + // 2. Start Local Server + server = http.createServer(async (req, res) => { + const reqUrl = new URL(req.url || "", `http://localhost:${port}`); + + if (reqUrl.pathname !== parsedRedirect.pathname) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + const code = reqUrl.searchParams.get("code"); + const returnedState = reqUrl.searchParams.get("state"); + const error = reqUrl.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(`

Authorization Failed

${error}

`); + cleanup(); + reject(new ToolError(`Authorization failed: ${error}`)); + return; + } + + if (returnedState !== state) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(`

Invalid State

State mismatch potential CSRF

`); + cleanup(); + reject(new ToolError("State mismatch")); + return; + } + + if (!code) { + res.writeHead(400); + res.end("Missing code"); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authorization Successful

You can close this window and return to the application.

", + ); + + // 3. Exchange Code for Token + try { + const tokenBody = { + grant_type: "authorization_code", + client_id: clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: verifier, + }; + + const response = await fetch(`${authorityUrl}/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(tokenBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Token exchange failed: ${response.status} ${errorText}`, + ); + } + + const tokenData = (await response.json()) as TokenResponse; + cleanup(); + resolve(tokenData); + } catch (err: any) { + cleanup(); + reject(new ToolError(`Failed to exchange token: ${err.message}`)); + } + }); + + server.listen(port, () => { + if (onOpenUrl) { + onOpenUrl(authUrl.toString()); + } else { + console.error( + `\n[BugSnag] Opening browser to: ${authUrl.toString()}`, + ); + OAuthService.openBrowser(authUrl.toString()); + } + }); + + server.on("error", (err) => { + cleanup(); + reject(new ToolError(`Failed to start local server: ${err.message}`)); + }); + }); } } From 4f2ef561d2d226a3311ebc6bd936e188f44ba223 Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Thu, 12 Feb 2026 13:47:02 +0000 Subject: [PATCH 04/10] feat: add support for oauth --- package-lock.json | 124 +++++++------------- package.json | 2 +- scripts/test-auth-flow.ts | 68 ----------- src/bugsnag/client.ts | 77 ++++--------- src/bugsnag/oauth.ts | 177 ---------------------------- src/common/transport-http.ts | 210 +++++++++++++++++++++++++++++++--- src/common/transport-stdio.ts | 6 + 7 files changed, 268 insertions(+), 396 deletions(-) delete mode 100644 scripts/test-auth-flow.ts delete mode 100644 src/bugsnag/oauth.ts diff --git a/package-lock.json b/package-lock.json index 68ce4602..963cdd9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@bugsnag/js": "^8.2.0", - "@modelcontextprotocol/sdk": "^1.15.0", + "@modelcontextprotocol/sdk": "^1.26.0", "node-cache": "^5.1.2", "swagger-client": "^3.35.6", "vite": "^7.3.1", @@ -755,10 +755,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", - "license": "MIT", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "engines": { "node": ">=18.14.1" }, @@ -834,12 +833,11 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", - "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", - "license": "MIT", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -847,14 +845,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -2048,7 +2047,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -2174,10 +2172,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -2185,7 +2182,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -2221,7 +2218,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2253,7 +2249,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -2344,7 +2339,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -2357,7 +2351,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2366,7 +2359,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2375,7 +2367,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", "engines": { "node": ">=6.6.0" } @@ -2467,7 +2458,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2496,8 +2486,7 @@ "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -2510,7 +2499,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2629,8 +2617,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/estree-walker": { "version": "3.0.3", @@ -2646,7 +2633,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2686,7 +2672,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2726,10 +2711,12 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2789,7 +2776,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2884,7 +2870,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2893,7 +2878,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3062,11 +3046,9 @@ } }, "node_modules/hono": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", - "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", - "license": "MIT", - "peer": true, + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "engines": { "node": ">=16.9.0" } @@ -3082,7 +3064,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -3099,10 +3080,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -3142,11 +3122,18 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", "engines": { "node": ">= 0.10" } @@ -3180,8 +3167,7 @@ "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, "node_modules/iserror": { "version": "0.0.2", @@ -3376,7 +3362,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3385,7 +3370,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", "engines": { "node": ">=18" }, @@ -3397,7 +3381,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3406,7 +3389,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, @@ -3491,7 +3473,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3595,7 +3576,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3607,7 +3587,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -3659,7 +3638,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3711,7 +3689,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -3802,7 +3779,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -3831,7 +3807,6 @@ "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" }, @@ -3872,7 +3847,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3881,7 +3855,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -3982,7 +3955,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -3997,8 +3969,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/semver": { "version": "7.7.3", @@ -4017,7 +3988,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -4043,7 +4013,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -4061,8 +4030,7 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -4134,7 +4102,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -4153,7 +4120,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -4169,7 +4135,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4187,7 +4152,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4257,7 +4221,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -4564,7 +4527,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", "engines": { "node": ">=0.6" } @@ -4617,7 +4579,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -4661,7 +4622,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } diff --git a/package.json b/package.json index 2094fe3c..92a2f8a5 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@bugsnag/js": "^8.2.0", - "@modelcontextprotocol/sdk": "^1.15.0", + "@modelcontextprotocol/sdk": "^1.26.0", "node-cache": "^5.1.2", "swagger-client": "^3.35.6", "vite": "^7.3.1", diff --git a/scripts/test-auth-flow.ts b/scripts/test-auth-flow.ts deleted file mode 100644 index 68f92867..00000000 --- a/scripts/test-auth-flow.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { spawn } from "child_process"; -import { OAuthService } from "../src/bugsnag/oauth"; - -const MOCK_SERVER_PORT = 3001; -const CLIENT_PORT = 8999; -const AUTHORITY_URL = `http://localhost:${MOCK_SERVER_PORT}`; -const REDIRECT_URI = `http://localhost:${CLIENT_PORT}/callback`; - -async function runTest() { - console.log("Starting Mock OAuth Server..."); - const mockServer = spawn("node", ["scripts/mock-oauth-server.cjs"], { - stdio: "inherit", - }); - - // Wait for mock server to be ready - await new Promise((resolve) => setTimeout(resolve, 1000)); - - console.log("Starting Auth Flow..."); - try { - const tokenPromise = OAuthService.startAuthCodeFlow( - AUTHORITY_URL, - "test-client-id", - REDIRECT_URI, - "api", - async (url) => { - console.log(`[Test] Received auth URL: ${url}`); - console.log("[Test] Simulating browser visit..."); - - // Simulate visiting the URL - // We need to fetch it and follow redirects - try { - const res = await fetch(url); - console.log(`[Test] Browser visited URL, status: ${res.status}`); - const text = await res.text(); - if (text.includes("Authorization Successful")) { - console.log("[Test] Browser saw success message."); - } else { - console.error("[Test] Browser did NOT see success message:", text); - } - } catch (e) { - console.error("[Test] Error visiting URL:", e); - } - }, - ); - - const token = await tokenPromise; - console.log("Auth Flow Complete!"); - console.log("Received Token:", token); - - if ( - token.access_token && - token.access_token.startsWith("mock-access-token") - ) { - console.log("SUCCESS: Token looks valid."); - } else { - console.error("FAILURE: Token is invalid."); - process.exit(1); - } - } catch (error) { - console.error("Auth Flow Failed:", error); - process.exit(1); - } finally { - mockServer.kill(); - process.exit(0); - } -} - -runTest(); diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index 917bf1a2..ce1dacb0 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import type { CacheService } from "../common/cache"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info"; import type { SmartBearMcpServer } from "../common/server"; -import { TokenStore } from "../common/token-store"; import { ToolError } from "../common/tools"; import type { Client, @@ -25,7 +24,6 @@ import type { import { ProjectAPI } from "./client/api/Project"; import { type FilterObject, toUrlSearchParams } from "./client/filters"; import { toolInputParameters } from "./input-schemas"; -import { OAuthService } from "./oauth"; const HUB_PREFIX = "00000"; const DEFAULT_DOMAIN = "bugsnag.com"; @@ -66,10 +64,18 @@ interface StabilityData { const ConfigurationSchema = z.object({ auth_token: z .string() - .describe("BugSnag personal authentication token") + .describe( + "BugSnag authentication token (personal access token or OAuth token)", + ) .optional(), project_api_key: z.string().describe("BugSnag project API key").optional(), - endpoint: z.string().url().describe("BugSnag endpoint URL").optional(), + endpoint: z.url().describe("BugSnag endpoint URL").optional(), + allow_unauthenticated: z.coerce + .boolean() + .describe( + "Allow the client to be configured without an auth token (tools will be listed but may fail)", + ) + .default(false), }); export class BugsnagClient implements Client { @@ -81,7 +87,6 @@ export class BugsnagClient implements Client { private _errorsApi: ErrorAPI | undefined; private _projectApi: ProjectAPI | undefined; private _appEndpoint: string | undefined; - private _config: z.infer | undefined; get currentUserApi(): CurrentUserAPI { if (!this._currentUserApi) throw new Error("Client not configured"); @@ -112,7 +117,6 @@ export class BugsnagClient implements Client { server: SmartBearMcpServer, config: z.infer, ): Promise { - this._config = config; this.cache = server.getCache(); this._appEndpoint = this.getEndpoint( "app", @@ -120,64 +124,27 @@ export class BugsnagClient implements Client { config.endpoint, ); - let authToken = config.auth_token; + const authToken = config.auth_token; - if (!authToken) { - // Try to load from store - const tokenData = await TokenStore.load("bugsnag"); - if (tokenData && tokenData.accessToken) { - // Check expiry if needed, but for now just use it - authToken = tokenData.accessToken; - } - } + // Parse config to check for allow_unauthenticated (coerce handles string "true" -> boolean true) + const parsedConfig = ConfigurationSchema.safeParse(config); + const allowUnauthenticated = + parsedConfig.success && parsedConfig.data.allow_unauthenticated; - console.log("authToken after checking store:", authToken); if (!authToken) { - console.error( - "No authentication token provided for BugSnag. Starting automated authentication flow...", - ); - this.authenticate(); - // We still mark as configured to allow tools to be registered, but they will fail until auth completes - this._isConfigured = true; + if (allowUnauthenticated) { + console.error( + "No authentication token provided for BugSnag client. Tools will be listed but may fail when invoked.", + ); + this._isConfigured = true; + return; + } return; } await this.initializeApis(authToken, config); } - private async authenticate() { - const authority = - process.env.BUGSNAG_OAUTH_AUTHORITY || "http://localhost:8080"; - const clientId = process.env.BUGSNAG_OAUTH_CLIENT_ID || "mcp-client"; - const redirectUri = - process.env.BUGSNAG_REDIRECT_URI || "http://localhost:8080/callback"; - - try { - console.error(`\n\n[BugSnag] Authentication Required`); - console.error(`Starting Authorization Code Flow with PKCE...`); - - const tokenParams = await OAuthService.startAuthCodeFlow( - authority, - clientId, - redirectUri, - "api", - ); - - // Save and init - await TokenStore.save("bugsnag", { - accessToken: tokenParams.access_token, - expiresAt: Math.floor(Date.now() / 1000) + tokenParams.expires_in, - }); - - if (this._config) { - await this.initializeApis(tokenParams.access_token, this._config); - } - console.error("\n[BugSnag] Successfully authenticated!\n"); - } catch (e: any) { - console.error("[BugSnag] Authentication failed:", e.message); - } - } - private async initializeApis( authToken: string, config: z.infer, diff --git a/src/bugsnag/oauth.ts b/src/bugsnag/oauth.ts deleted file mode 100644 index 24232c3c..00000000 --- a/src/bugsnag/oauth.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { spawn } from "child_process"; -import { createHash, randomBytes } from "crypto"; -import * as http from "http"; -import { URL } from "url"; -import { ToolError } from "../common/tools"; - -export interface TokenResponse { - access_token: string; - token_type: string; - expires_in: number; - refresh_token?: string; - scope?: string; -} - -export class OAuthService { - private static base64URLEncode(buffer: Buffer): string { - return buffer - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); - } - - private static sha256(str: string): Buffer { - return createHash("sha256").update(str).digest(); - } - - private static generateVerifier(): string { - return OAuthService.base64URLEncode(randomBytes(32)); - } - - private static generateChallenge(verifier: string): string { - return OAuthService.base64URLEncode(OAuthService.sha256(verifier)); - } - - private static openBrowser(url: string) { - const start = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - spawn(start, [url], { detached: true, stdio: "ignore" }).unref(); - } - - static async startAuthCodeFlow( - authorityUrl: string, - clientId: string, - redirectUri: string, - scope = "api", - onOpenUrl?: (url: string) => void, - ): Promise { - const verifier = OAuthService.generateVerifier(); - const challenge = OAuthService.generateChallenge(verifier); - const state = OAuthService.base64URLEncode(randomBytes(16)); - const parsedRedirect = new URL(redirectUri); - const port = parseInt(parsedRedirect.port) || 80; - - // 1. Construct Authorization URL - const authUrl = new URL(`${authorityUrl}/authorize`); - authUrl.searchParams.append("response_type", "code"); - authUrl.searchParams.append("client_id", clientId); - authUrl.searchParams.append("redirect_uri", redirectUri); - authUrl.searchParams.append("scope", scope); - authUrl.searchParams.append("state", state); - authUrl.searchParams.append("code_challenge", challenge); - authUrl.searchParams.append("code_challenge_method", "S256"); - // Required by RFC 8707 Resource Indicators for OAuth 2.0 - // We assume the resource is the authority for now or derived from it, - // but the prompt implies we are authorizing *for* BugSnag. - // Let's assume the resource ID matches the authority or is configured. - // For now, we'll omit explicit resource param unless strictly needed, - // as some providers choke on it if not configured. - // But spec says "MUST include resource parameter". - // I'll add it if it's distinct, but for BugSnag likely it's the API base. - // Let's stick to core PKCE first. - - return new Promise((resolve, reject) => { - let server: http.Server; - - const cleanup = () => { - if (server) server.close(); - }; - - // 2. Start Local Server - server = http.createServer(async (req, res) => { - const reqUrl = new URL(req.url || "", `http://localhost:${port}`); - - if (reqUrl.pathname !== parsedRedirect.pathname) { - res.writeHead(404); - res.end("Not Found"); - return; - } - - const code = reqUrl.searchParams.get("code"); - const returnedState = reqUrl.searchParams.get("state"); - const error = reqUrl.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(`

Authorization Failed

${error}

`); - cleanup(); - reject(new ToolError(`Authorization failed: ${error}`)); - return; - } - - if (returnedState !== state) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(`

Invalid State

State mismatch potential CSRF

`); - cleanup(); - reject(new ToolError("State mismatch")); - return; - } - - if (!code) { - res.writeHead(400); - res.end("Missing code"); - return; - } - - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - "

Authorization Successful

You can close this window and return to the application.

", - ); - - // 3. Exchange Code for Token - try { - const tokenBody = { - grant_type: "authorization_code", - client_id: clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: verifier, - }; - - const response = await fetch(`${authorityUrl}/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(tokenBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Token exchange failed: ${response.status} ${errorText}`, - ); - } - - const tokenData = (await response.json()) as TokenResponse; - cleanup(); - resolve(tokenData); - } catch (err: any) { - cleanup(); - reject(new ToolError(`Failed to exchange token: ${err.message}`)); - } - }); - - server.listen(port, () => { - if (onOpenUrl) { - onOpenUrl(authUrl.toString()); - } else { - console.error( - `\n[BugSnag] Opening browser to: ${authUrl.toString()}`, - ); - OAuthService.openBrowser(authUrl.toString()); - } - }); - - server.on("error", (err) => { - cleanup(); - reject(new ToolError(`Failed to start local server: ${err.message}`)); - }); - }); - } -} diff --git a/src/common/transport-http.ts b/src/common/transport-http.ts index ed1bb40e..8f6f85cc 100644 --- a/src/common/transport-http.ts +++ b/src/common/transport-http.ts @@ -11,6 +11,16 @@ import { SmartBearMcpServer } from "./server"; import type { Client } from "./types"; import { isOptionalType } from "./zod-utils"; +/** + * Helper to construct the base URL from the request, respecting proxy headers. + * This is critical for cloud deployments where SSL termination happens at the load balancer. + */ +function getBaseUrl(req: IncomingMessage): string { + const protocol = (req.headers["x-forwarded-proto"] as string) || "http"; + const host = (req.headers["x-forwarded-host"] as string) || req.headers.host; + return `${protocol}://${host}`; +} + /** * Run server in HTTP mode with Streamable HTTP transport * Supports both SSE (legacy) and StreamableHTTP transports for backwards compatibility @@ -20,6 +30,7 @@ export async function runHttpMode() { const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [ "http://localhost:3000", ]; + const baseUrlOverride = process.env.BASE_URL; // Allow explicit override if headers are unreliable // Store transports by session ID const transports = new Map< @@ -37,6 +48,7 @@ export async function runHttpMode() { "Authorization", "MCP-Session-Id", // Required for StreamableHTTP "x-custom-auth-headers", // used by mcp-inspector + "mcp-protocol-version", ...allowedAuthHeaders, ].join(", "); @@ -60,7 +72,10 @@ export async function runHttpMode() { return; } - const url = new URL(req.url || "/", `http://${req.headers.host}`); + // Determine the public URL of this server + // Use env override if set, otherwise detect from request headers + const baseUrl = baseUrlOverride || getBaseUrl(req); + const url = new URL(req.url || "/", baseUrl); // HEALTH CHECK ENDPOINT if (req.method === "GET" && url.pathname === "/health") { @@ -71,6 +86,131 @@ export async function runHttpMode() { return; } + // OAUTH DISCOVERY ENDPOINT (RFC 8414) + if ( + req.method === "GET" && + (url.pathname === "/.well-known/oauth-authorization-server" || + url.pathname === "/.well-known/oauth-authorization-server/mcp") + ) { + const issuer = + process.env.OAUTH_ISSUER || "https://oauth.smartbear.com"; + const authEndpoint = + process.env.OAUTH_AUTHORIZATION_ENDPOINT || `${issuer}/authorize`; + const tokenEndpoint = + process.env.OAUTH_TOKEN_ENDPOINT || `${issuer}/token`; + const jwksUri = + process.env.OAUTH_JWKS_URI || `${issuer}/.well-known/jwks.json`; + + // We provide a local registration endpoint to satisfy MCP Inspector + // which returns a pre-configured client ID + const registrationEndpoint = `${baseUrl}/oauth/register`; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + issuer: issuer, + authorization_endpoint: authEndpoint, + token_endpoint: tokenEndpoint, + jwks_uri: jwksUri, + registration_endpoint: registrationEndpoint, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: process.env.OAUTH_SCOPES + ? process.env.OAUTH_SCOPES.split(",") + : ["api"], + }), + ); + return; + } + + // PROTECTED RESOURCE METADATA ENDPOINT (RFC 9293) + // This endpoint tells the client where to find the Authorization Server. + // The Inspector hits this first to find the Auth Server, then hits /.well-known/oauth-authorization-server. + // We point to ourselves (or the configured issuer) so the client can find the metadata above. + if ( + req.method === "GET" && + (url.pathname === "/.well-known/oauth-protected-resource" || + url.pathname === "/.well-known/oauth-protected-resource/mcp") + ) { + // In this architecture, the MCP server acts as the discovery gateway for the Auth Server. + // We point the client to this server's host to fetch the authorization server metadata. + // Note: The 'issuer' in the metadata above might be different (external), but we want + // the client to discover the metadata *here* first to get our registration_endpoint. + // If we pointed directly to an external issuer, we'd lose the ability to inject the + // mock registration endpoint. + const authServerUrl = baseUrl; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + resource: `${baseUrl}/mcp`, + authorization_servers: [authServerUrl], + }), + ); + return; + } + + // DYNAMIC CLIENT REGISTRATION ENDPOINT + // This endpoint implements a stateless version of RFC 7591 dynamic client registration. + // It allows clients (like MCP Inspector) to register themselves to obtain a client_id. + // Since this server is stateless, we return a deterministic or configured client_id + // rather than persisting client records in a database. + // The Inspector calls this to get the client_id before constructing the authorization URL. + if (req.method === "POST" && url.pathname === "/oauth/register") { + try { + // Consume the body + const body = (await parseRequestBody(req)) as Record; + const redirectUris = body?.redirect_uris as string[] | undefined; + + // RFC 7591: redirect_uris is required for web clients + if ( + !redirectUris || + !Array.isArray(redirectUris) || + redirectUris.length === 0 + ) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "invalid_redirect_uri", + error_description: "redirect_uris parameter is required", + }), + ); + return; + } + + // Use configured client ID or default to a static one for stateless operation + const clientId = process.env.OAUTH_CLIENT_ID || "mcp-client"; + + // Determine scopes: Use requested scopes if valid, or default to all supported + const supportedScopes = process.env.OAUTH_SCOPES + ? process.env.OAUTH_SCOPES.split(",") + : ["api", "offline_access"]; + + res.writeHead(201, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + client_id: clientId, + client_name: body.client_name || "MCP Client", + redirect_uris: redirectUris, + scope: supportedScopes.join(" "), + client_secret_expires_at: 0, + client_id_issued_at: Math.floor(Date.now() / 1000), + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + }), + ); + } catch (error) { + console.error("Error handling registration request:", error); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_request" })); + } + return; + } + // STREAMABLE HTTP ENDPOINT (modern, preferred) if (url.pathname === "/mcp") { await handleStreamableHttpRequest(req, res, transports); @@ -450,17 +590,50 @@ async function newServer( const server = new SmartBearMcpServer(); try { // Configure server with values from HTTP headers - await clientRegistry.configure(server, (client, key) => { - const headerName = getHeaderName(client, key); - // Check both original case and lower-case headers for compatibility - // (HTTP headers are case-insensitive, but Node.js lowercases them) - const value = - req.headers[headerName] || req.headers[headerName.toLowerCase()]; - if (typeof value === "string") { - return value; - } - return null; - }); + const configuredCount = await clientRegistry.configure( + server, + (client, key) => { + const headerName = getHeaderName(client, key); + // Check both original case and lower-case headers for compatibility + // (HTTP headers are case-insensitive, but Node.js lowercases them) + const value = + req.headers[headerName] || req.headers[headerName.toLowerCase()]; + if (typeof value === "string") { + return value; + } + + // Check standard Authorization header as fallback + // This supports the MCP Inspector which sends the obtained OAuth token in the Authorization header + // We map this token to the primary authentication config key of the client + const isAuthKey = [ + "auth_token", + "api_token", + "api_key", + "token", + "login_ticket", + ].includes(key); + + if (isAuthKey && req.headers.authorization) { + const authHeader = req.headers.authorization; + if (authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return authHeader; + } + + return null; + }, + ); + + console.log( + `Configured ${configuredCount} clients for new server instance`, + ); + + if (configuredCount === 0) { + throw new Error( + "No clients successfully configured. Missing authentication headers.", + ); + } } catch (error: any) { // Configuration failed - provide helpful error message const headerHelp = getHttpHeadersHelp(); @@ -469,7 +642,18 @@ async function newServer( ? `Configuration error: ${error instanceof Error ? error.message : String(error)}. Please provide valid headers:\n${headerHelp.join("\n")}` : "No clients support HTTP header configuration."; - res.writeHead(401, { "Content-Type": "text/plain" }); + const headers: Record = { + "Content-Type": "text/plain", + }; + + // Add WWW-Authenticate header to support OAuth discovery flow + // This points the client to the Protected Resource Metadata endpoint + if (req.headers.host) { + headers["WWW-Authenticate"] = + `OAuth resource_metadata="http://${req.headers.host}/.well-known/oauth-protected-resource"`; + } + + res.writeHead(401, headers); res.end(errorMessage); return null; } diff --git a/src/common/transport-stdio.ts b/src/common/transport-stdio.ts index 504b102c..e3704a29 100644 --- a/src/common/transport-stdio.ts +++ b/src/common/transport-stdio.ts @@ -41,6 +41,12 @@ export async function runStdioMode() { const configuredCount = await clientRegistry.configure( server, (client, key) => { + // Force enable allow_unauthenticated for BugSnag in stdio mode + // This ensures tools are listed even if the user hasn't provided a token yet + if (client.name === "BugSnag" && key === "allow_unauthenticated") { + return "true"; + } + const envVarName = getEnvVarName(client, key); return process.env[envVarName] || null; }, From 5b7857fbd374e0c50c4a99cf5c19615496049ffc Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Thu, 12 Feb 2026 13:53:16 +0000 Subject: [PATCH 05/10] fix: tests --- src/bugsnag/client.ts | 12 ++++--- src/tests/unit/bugsnag/client.test.ts | 52 ++++++++++++++++++--------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index ce1dacb0..df960610 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -9,10 +9,6 @@ import type { RegisterResourceFunction, RegisterToolsFunction, } from "../common/types"; -import { ErrorUpdateRequest } from "./client/api/api"; -import { CurrentUserAPI } from "./client/api/CurrentUser"; -import { Configuration } from "./client/api/configuration"; -import { ErrorAPI } from "./client/api/Error"; import type { Build, EventField, @@ -21,7 +17,13 @@ import type { Release, TraceField, } from "./client/api/index"; -import { ProjectAPI } from "./client/api/Project"; +import { + Configuration, + CurrentUserAPI, + ErrorAPI, + ErrorUpdateRequest, + ProjectAPI, +} from "./client/api/index"; import { type FilterObject, toUrlSearchParams } from "./client/filters"; import { toolInputParameters } from "./input-schemas"; diff --git a/src/tests/unit/bugsnag/client.test.ts b/src/tests/unit/bugsnag/client.test.ts index b68be823..c37c6986 100644 --- a/src/tests/unit/bugsnag/client.test.ts +++ b/src/tests/unit/bugsnag/client.test.ts @@ -67,23 +67,29 @@ const mockCache = { del: vi.fn(), }; -vi.mock("../../../bugsnag/client/api/index.js", () => ({ - ...vi.importActual("../../../bugsnag/client/api/index.js"), - CurrentUserAPI: vi.fn().mockImplementation(() => mockCurrentUserAPI), - ErrorAPI: vi.fn().mockImplementation(() => mockErrorAPI), - ProjectAPI: vi.fn().mockImplementation(() => mockProjectAPI), - Configuration: vi.fn().mockImplementation((config) => config), - ErrorUpdateRequest: { - OperationEnum: { - Fix: "fix", - Ignore: "ignore", - OverrideSeverity: "override_severity", - Open: "open", - Discard: "discard", - Undiscard: "undiscard", +vi.mock("../../../bugsnag/client/api/index.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../bugsnag/client/api/index.js") + >(); + return { + ...actual, + CurrentUserAPI: vi.fn().mockImplementation(() => mockCurrentUserAPI), + ErrorAPI: vi.fn().mockImplementation(() => mockErrorAPI), + ProjectAPI: vi.fn().mockImplementation(() => mockProjectAPI), + Configuration: vi.fn().mockImplementation((config) => config), + ErrorUpdateRequest: { + OperationEnum: { + Fix: "fix", + Ignore: "ignore", + OverrideSeverity: "override_severity", + Open: "open", + Discard: "discard", + Undiscard: "undiscard", + }, }, - }, -})); + }; +}); vi.mock("../../../common/bugsnag.js", () => ({ default: { @@ -129,6 +135,7 @@ async function createConfiguredClient( auth_token: authToken, project_api_key: projectApiKey, endpoint, + allow_unauthenticated: false, }); mockCache.get.mockClear(); return client; @@ -550,7 +557,10 @@ describe("BugsnagClient", () => { getCache: vi.fn().mockReturnValue(mockCache), } as any; - await client.configure(mockServer, { auth_token: "test-token" }); + await client.configure(mockServer, { + auth_token: "test-token", + allow_unauthenticated: false, + }); // Cache should be used in getProjects mockCache.get.mockReturnValueOnce(null); // No cached org @@ -604,6 +614,7 @@ describe("BugsnagClient", () => { await testClient.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", + allow_unauthenticated: false, }); expect(mockCurrentUserAPI.listUserOrganizations).toHaveBeenCalledOnce(); @@ -641,6 +652,7 @@ describe("BugsnagClient", () => { await clientWithApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", project_api_key: "project-api-key", + allow_unauthenticated: false, }); expect(mockCache.set).toHaveBeenCalledWith( @@ -671,6 +683,7 @@ describe("BugsnagClient", () => { await clientWithNoApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", + allow_unauthenticated: false, }); expect(mockCache.set).not.toHaveBeenCalledWith( @@ -693,6 +706,7 @@ describe("BugsnagClient", () => { await clientWithNoApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", + allow_unauthenticated: false, }); expect(mockCache.set).toHaveBeenCalledWith( @@ -708,6 +722,7 @@ describe("BugsnagClient", () => { await expect( client.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", + allow_unauthenticated: false, }), ).resolves.toBe(undefined); expect(client.isConfigured()).toBe(false); @@ -722,6 +737,7 @@ describe("BugsnagClient", () => { await clientWithApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", project_api_key: "non-existent-key", + allow_unauthenticated: false, }); const mockOrg = getMockOrganization("org-1", "Test Org"); const mockProject = getMockProject("proj-1", "Project 1", "other-key"); @@ -737,6 +753,7 @@ describe("BugsnagClient", () => { clientWithApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", project_api_key: "non-existent-key", + allow_unauthenticated: false, }), ).resolves.toBe(undefined); expect(clientWithApiKey.isConfigured()).toBe(true); @@ -764,6 +781,7 @@ describe("BugsnagClient", () => { clientWithApiKey.configure({ getCache: () => mockCache } as any, { auth_token: "test-token", project_api_key: "project-api-key", + allow_unauthenticated: false, }), ).resolves.toBe(undefined); expect(clientWithApiKey.isConfigured()).toBe(true); From 110a323b8883da0afccf9046cac6ad4744834fad Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Wed, 18 Feb 2026 10:01:53 +0000 Subject: [PATCH 06/10] chore: configure endpoint via env --- src/common/transport-http.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/common/transport-http.ts b/src/common/transport-http.ts index 8f6f85cc..e6253524 100644 --- a/src/common/transport-http.ts +++ b/src/common/transport-http.ts @@ -602,6 +602,16 @@ async function newServer( return value; } + // For BugSnag client, allow reading endpoint from environment variable + // This is useful for On-Premise installations where the endpoint is fixed + if (client.name === "BugSnag" && key === "endpoint") { + const envEndpoint = + process.env.BUGSNAG_API_URL || process.env.BUGSNAG_ENDPOINT; + if (envEndpoint) { + return envEndpoint; + } + } + // Check standard Authorization header as fallback // This supports the MCP Inspector which sends the obtained OAuth token in the Authorization header // We map this token to the primary authentication config key of the client From 183a52f5c645415cb8123561bd21f24f7c6b123f Mon Sep 17 00:00:00 2001 From: Sachin Pande Date: Wed, 4 Mar 2026 16:52:54 +0000 Subject: [PATCH 07/10] fix: remove allow_unauthenticated config option --- src/bugsnag/client.ts | 18 ------------------ src/common/transport-stdio.ts | 6 ------ src/tests/unit/bugsnag/client.test.ts | 2 -- 3 files changed, 26 deletions(-) diff --git a/src/bugsnag/client.ts b/src/bugsnag/client.ts index ed5e1454..030202e4 100644 --- a/src/bugsnag/client.ts +++ b/src/bugsnag/client.ts @@ -82,12 +82,6 @@ const ConfigurationSchema = z.object({ .optional(), project_api_key: z.string().describe("BugSnag project API key").optional(), endpoint: z.url().describe("BugSnag endpoint URL").optional(), - allow_unauthenticated: z.coerce - .boolean() - .describe( - "Allow the client to be configured without an auth token (tools will be listed but may fail)", - ) - .default(false), }); export class BugsnagClient implements Client { @@ -137,19 +131,7 @@ export class BugsnagClient implements Client { const authToken = config.auth_token; - // Parse config to check for allow_unauthenticated (coerce handles string "true" -> boolean true) - const parsedConfig = ConfigurationSchema.safeParse(config); - const allowUnauthenticated = - parsedConfig.success && parsedConfig.data.allow_unauthenticated; - if (!authToken) { - if (allowUnauthenticated) { - console.error( - "No authentication token provided for BugSnag client. Tools will be listed but may fail when invoked.", - ); - this._isConfigured = true; - return; - } return; } diff --git a/src/common/transport-stdio.ts b/src/common/transport-stdio.ts index e3704a29..504b102c 100644 --- a/src/common/transport-stdio.ts +++ b/src/common/transport-stdio.ts @@ -41,12 +41,6 @@ export async function runStdioMode() { const configuredCount = await clientRegistry.configure( server, (client, key) => { - // Force enable allow_unauthenticated for BugSnag in stdio mode - // This ensures tools are listed even if the user hasn't provided a token yet - if (client.name === "BugSnag" && key === "allow_unauthenticated") { - return "true"; - } - const envVarName = getEnvVarName(client, key); return process.env[envVarName] || null; }, diff --git a/src/tests/unit/bugsnag/client.test.ts b/src/tests/unit/bugsnag/client.test.ts index ea130d03..811a7e63 100644 --- a/src/tests/unit/bugsnag/client.test.ts +++ b/src/tests/unit/bugsnag/client.test.ts @@ -124,7 +124,6 @@ async function createConfiguredClient( auth_token: authToken, project_api_key: projectApiKey, endpoint, - allow_unauthenticated: false, }); mockCache.get.mockClear(); return client; @@ -548,7 +547,6 @@ describe("BugsnagClient", () => { await client.configure(mockServer, { auth_token: "test-token", - allow_unauthenticated: false, }); // Cache should be used in getProjects From 9053882d4ba59c50275db6eecdd9d9bb2a1f145b Mon Sep 17 00:00:00 2001 From: Victor Martinez Roig Date: Tue, 17 Mar 2026 10:13:02 +0100 Subject: [PATCH 08/10] register for jira needed changes --- src/common/transport-http.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/common/transport-http.ts b/src/common/transport-http.ts index e6253524..a5ab1383 100644 --- a/src/common/transport-http.ts +++ b/src/common/transport-http.ts @@ -184,9 +184,9 @@ export async function runHttpMode() { const clientId = process.env.OAUTH_CLIENT_ID || "mcp-client"; // Determine scopes: Use requested scopes if valid, or default to all supported - const supportedScopes = process.env.OAUTH_SCOPES - ? process.env.OAUTH_SCOPES.split(",") - : ["api", "offline_access"]; + // const supportedScopes = process.env.OAUTH_SCOPES + // ? process.env.OAUTH_SCOPES.split(",") + // : ["api", "offline_access"]; res.writeHead(201, { "Content-Type": "application/json" }); res.end( @@ -194,13 +194,13 @@ export async function runHttpMode() { client_id: clientId, client_name: body.client_name || "MCP Client", redirect_uris: redirectUris, - scope: supportedScopes.join(" "), - client_secret_expires_at: 0, + // scope: supportedScopes.join(" "), + // client_secret_expires_at: 0, client_id_issued_at: Math.floor(Date.now() / 1000), grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", - application_type: "web", + // application_type: "web", }), ); } catch (error) { From b0b06a688a4e9ba1c618403c6b753985f26fe041 Mon Sep 17 00:00:00 2001 From: Victor Martinez Roig Date: Wed, 18 Mar 2026 12:13:21 +0100 Subject: [PATCH 09/10] using localhost for tests --- src/zephyr/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zephyr/client.ts b/src/zephyr/client.ts index d4be823b..985942e0 100644 --- a/src/zephyr/client.ts +++ b/src/zephyr/client.ts @@ -30,7 +30,7 @@ import { GetTestExecution } from "./tool/test-execution/get-test-execution"; import { GetTestExecutions } from "./tool/test-execution/get-test-executions"; import { UpdateTestExecution } from "./tool/test-execution/update-test-execution"; -const BASE_URL_DEFAULT = "https://api.zephyrscale.smartbear.com/v2"; +const BASE_URL_DEFAULT = "http://localhost:5051/v2"; const ConfigurationSchema = z.object({ api_token: z.string().describe("Zephyr Scale API token for authentication"), From 586d0536a988f1f51dd3a05f21288c2bd9f4e0eb Mon Sep 17 00:00:00 2001 From: Victor Martinez Roig Date: Wed, 18 Mar 2026 12:42:36 +0100 Subject: [PATCH 10/10] adding todo for poc --- src/zephyr/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zephyr/client.ts b/src/zephyr/client.ts index 985942e0..e3a0608e 100644 --- a/src/zephyr/client.ts +++ b/src/zephyr/client.ts @@ -30,7 +30,7 @@ import { GetTestExecution } from "./tool/test-execution/get-test-execution"; import { GetTestExecutions } from "./tool/test-execution/get-test-executions"; import { UpdateTestExecution } from "./tool/test-execution/update-test-execution"; -const BASE_URL_DEFAULT = "http://localhost:5051/v2"; +const BASE_URL_DEFAULT = "http://localhost:5051/v2"; // TODO by now only for the POC const ConfigurationSchema = z.object({ api_token: z.string().describe("Zephyr Scale API token for authentication"),