Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b452de2
feat(bugsnag): automate oauth flow on startup if unauthenticated
sazap10 Feb 6, 2026
dab67b7
feat: oauth login
sazap10 Feb 9, 2026
1c33a91
feat: more oauth flow
sazap10 Feb 9, 2026
4f2ef56
feat: add support for oauth
sazap10 Feb 12, 2026
5b7857f
fix: tests
sazap10 Feb 12, 2026
110a323
chore: configure endpoint via env
sazap10 Feb 18, 2026
092a7c4
Merge branch 'main' into oauth_server_bugsnag
sazap10 Mar 4, 2026
183a52f
fix: remove allow_unauthenticated config option
sazap10 Mar 4, 2026
3486cdf
chore: update biome schema
sazap10 Mar 10, 2026
9d96136
feat: update auth middleware for oauth flow
sazap10 Mar 10, 2026
be5856d
chore: reduce coverage threshold
sazap10 Mar 10, 2026
810786f
feat: remove oauth registration
sazap10 Mar 11, 2026
eba84e2
feat: remove oauth discovery
sazap10 Mar 12, 2026
688d824
Merge branch 'main' into oauth_server_bugsnag
sazap10 Mar 24, 2026
967e68e
feat: make auth tokens optional for oauth flow
sazap10 Mar 24, 2026
5eebab6
Merge branch 'main' into oauth_server_bugsnag
sazap10 Mar 24, 2026
36a1c08
feat: update dockerfile to work with development and push to latest
sazap10 Mar 30, 2026
83ea9ee
chore: ignore dl3006
sazap10 Mar 30, 2026
6fe67cb
feat: trigger oauth if not authed
sazap10 Mar 31, 2026
b0c1c03
chore: lint
sazap10 Mar 31, 2026
32be59d
fix: auth prefix correctly for bugsnag
sazap10 Mar 31, 2026
30bf065
chore: linting
sazap10 Mar 31, 2026
07053a1
chore: remove debug
sazap10 Apr 2, 2026
746cc25
Merge branch 'main' into oauth_server_bugsnag
sazap10 Apr 2, 2026
94ceebf
chore: review comments
sazap10 Apr 7, 2026
8d4a4a5
chore: remove redundant bugsnag_endpoint lookup
sazap10 Apr 7, 2026
7819d68
chore: add tests for new functions
sazap10 Apr 7, 2026
fc5a7c6
chore: add tests for oauth flow
sazap10 Apr 7, 2026
55ec789
chore: update comment for request-context
sazap10 Apr 7, 2026
b16b35d
Merge branch 'main' into oauth_server_bugsnag
tomlongridge Apr 7, 2026
d842b55
chore: update changelog
sazap10 Apr 7, 2026
06b2746
Merge branch 'oauth_server_bugsnag' of github.com:smartbear/smartbear…
sazap10 Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ jobs:
# Push the manifest to GHCR
docker manifest push "${GHCR_REGISTRY}:${IMAGE_TAG}"
# Create and push 'latest' tag manifest list
docker manifest create "${GHCR_REGISTRY}:latest" \
--amend "${GHCR_REGISTRY}:${IMAGE_TAG}-amd64" \
--amend "${GHCR_REGISTRY}:${IMAGE_TAG}-arm64"
docker manifest push "${GHCR_REGISTRY}:latest"
publish:
name: Publish package
Expand Down
2 changes: 2 additions & 0 deletions .hadolint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignored:
- DL3006 # We specify the builder image as a build argument, so we ignore this issue. See the Dockerfile for more details.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- [Zephyr] Added a tool `update-test-steps` for updating test execution test steps [#386](https://github.com/SmartBear/smartbear-mcp/pull/386)
- [Common] Added OAuth token support for HTTP transport clients. [#330](https://github.com/SmartBear/smartbear-mcp/pull/330)
- [BugSnag] Added "Get Events on an Error" tool for listing the events that have grouped into a specified error [#406](https://github.com/SmartBear/smartbear-mcp/pull/406)

### Changed
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM node:22-alpine AS builder
ARG BUILDER_IMAGE_NAME=node:22-alpine
FROM ${BUILDER_IMAGE_NAME} AS builder

# Must be entire project because `prepare` script is run during dependency installation and requires all files.
WORKDIR /app
Expand Down
70 changes: 66 additions & 4 deletions src/bugsnag/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { CacheService } from "../common/cache";
import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info";
import { getRequestHeader } from "../common/request-context";
import type { SmartBearMcpServer } from "../common/server";
import { ToolError } from "../common/tools";
import type {
Expand Down Expand Up @@ -70,7 +71,7 @@ interface StabilityData {
}

const ConfigurationSchema = z.object({
auth_token: z.string().describe("BugSnag personal authentication token"),
auth_token: z.string().describe("BugSnag personal access token").optional(),
project_api_key: z.string().describe("BugSnag project API key").optional(),
endpoint: z.string().url().describe("BugSnag endpoint URL").optional(),
});
Expand All @@ -83,6 +84,7 @@ export class BugsnagClient implements Client {
private _errorsApi: ErrorAPI | undefined;
private _projectApi: ProjectAPI | undefined;
private _appEndpoint: string | undefined;
private _authToken?: string;

get currentUserApi(): CurrentUserAPI {
if (!this._currentUserApi) throw new Error("Client not configured");
Expand Down Expand Up @@ -114,13 +116,75 @@ export class BugsnagClient implements Client {
config: z.infer<typeof ConfigurationSchema>,
): Promise<void> {
this.cache = server.getCache();

this._appEndpoint = this.getEndpoint(
"app",
config.project_api_key,
config.endpoint,
);
this._projectApiKey = config.project_api_key;
this._authToken = config.auth_token;

// Initialize APIs even if auth_token is missing, to allow request-level auth
await this.initializeApis(config);
}

getAuthToken(): string | null {
const contextHeader = getRequestHeader("Bugsnag-Auth-Token");
if (contextHeader) {
let token = Array.isArray(contextHeader)
? contextHeader[0]
: contextHeader;

// Handle token prefix if present
if (token.startsWith("token ")) {
token = token.substring(6);
}

return `token ${token}`;
}

// Fall back to Authorization header (used by OAuth flow)
const bearerToken = this.getBearerToken();
if (bearerToken) {
return bearerToken;
}

// Fall back to configured token (needs prefix for Authorization header)
return this._authToken ? `token ${this._authToken}` : null;
}

getBearerToken(): string | null {
const contextHeader = getRequestHeader("Authorization");

if (contextHeader) {
let token = Array.isArray(contextHeader)
? contextHeader[0]
: contextHeader;

// Handle Bearer prefix if present
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}

return `Bearer ${token}`;
}

return null;
}

private async initializeApis(config: z.infer<typeof ConfigurationSchema>) {
const apiConfig = new Configuration({
apiKey: `token ${config.auth_token}`,
apiKey: (_name: string) => {
const authToken = this.getAuthToken();
if (authToken) {
return authToken;
}

throw new Error(
"Authentication token not found in request headers or configuration",
);
},
headers: {
"User-Agent": `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`,
"Content-Type": "application/json",
Expand All @@ -136,9 +200,7 @@ export class BugsnagClient implements Client {
this._currentUserApi = new CurrentUserAPI(apiConfig);
this._errorsApi = new ErrorAPI(apiConfig);
this._projectApi = new ProjectAPI(apiConfig);
this._projectApiKey = config.project_api_key;
this._isConfigured = true;
return;
}

isConfigured(): boolean {
Expand Down
31 changes: 23 additions & 8 deletions src/collaborator/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { getRequestHeader } from "../common/request-context";
import type { SmartBearMcpServer } from "../common/server";
import type {
Client,
Expand All @@ -8,10 +9,14 @@ import type {

const ConfigurationSchema = z.object({
base_url: z.url().describe("Collaborator server base URL"),
username: z.string().describe("Collaborator username for authentication"),
username: z
.string()
.describe("Collaborator username for authentication")
.optional(),
login_ticket: z
.string()
.describe("Collaborator login ticket for authentication"),
.describe("Collaborator login ticket for authentication")
.optional(),
});

export class CollaboratorClient implements Client {
Expand All @@ -35,11 +40,7 @@ export class CollaboratorClient implements Client {
}

isConfigured(): boolean {
return (
this.baseUrl !== undefined &&
this.username !== undefined &&
this.loginTicket !== undefined
);
return this.baseUrl !== undefined;
}

/**
Expand All @@ -49,11 +50,25 @@ export class CollaboratorClient implements Client {
*/
async call(commands: any[]): Promise<any> {
const url = `${this.baseUrl}/services/json/v1`;

let login = this.username;
let ticket = this.loginTicket;

const contextLogin = getRequestHeader("Collaborator-Login");
const contextTicket = getRequestHeader("Collaborator-Ticket");

if (contextLogin) {
login = Array.isArray(contextLogin) ? contextLogin[0] : contextLogin;
}
if (contextTicket) {
ticket = Array.isArray(contextTicket) ? contextTicket[0] : contextTicket;
}

// Always prepend authentication command automatically
const body = [
{
command: "SessionService.authenticate",
args: { login: this.username, ticket: this.loginTicket },
args: { login, ticket },
},
...commands,
];
Expand Down
40 changes: 40 additions & 0 deletions src/common/request-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { IncomingMessage } from "node:http";

// Storage for pre-request data that can be retrieved from a tool to prevent caching as part of the server instance in a session.
// For example, the auth token.
export interface RequestContext {
headers: Record<string, string | string[] | undefined>;
}

// Create the storage instance
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();

/**
* Run a callback within the request context, extracting headers from the request.
* This ensures request headers are available via AsyncLocalStorage to downstream code.
*/
export function withRequestContext<T>(req: IncomingMessage, fn: () => T): T {
return requestContextStorage.run({ headers: req.headers }, fn);
}

// Helper to get the current context
export function getRequestContext(): RequestContext | undefined {
return requestContextStorage.getStore();
}

/**
* Helper to get a specific header from the current request
* @param name Header name (case-insensitive)
* @returns Header value or undefined if not found
*/
export function getRequestHeader(name: string): string | string[] | undefined {
const context = getRequestContext();
if (!context?.headers) return undefined;

// Headers are typically case-insensitive, but node http headers are lowercased
// We'll try exact match first, then lowercase match
const headerValue =
context.headers[name] || context.headers[name.toLowerCase()];
return headerValue;
}
4 changes: 4 additions & 0 deletions src/common/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export class SmartBearMcpServer extends McpServer {
return this.elicitationSupported;
}

getClients(): Client[] {
return this.clients;
}

async cleanupSession(mcpSessionId: string): Promise<void> {
for (const client of this.clients) {
await client.cleanupSession?.(mcpSessionId);
Expand Down
Loading
Loading