Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs/testing/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,27 @@ pnpm -w run cli "who am I?"
- Automatically refreshed when expired
- To force re-auth: delete the token file

**Running in a VM or container:**

The callback server defaults to `127.0.0.1:8765`, which a browser on the host
cannot reach. Three environment variables override it:

| Variable | Default | Purpose |
| --- | --- | --- |
| `MCP_OAUTH_PORT` | `8765` | Port the callback server listens on |
| `MCP_OAUTH_HOST` | `127.0.0.1` | Address the callback server binds to |
| `MCP_OAUTH_REDIRECT_URI` | `http://localhost:$MCP_OAUTH_PORT/callback` | Redirect URI sent to the OAuth server |

```bash
# Bind on all interfaces and send the browser to the forwarded address
MCP_OAUTH_HOST=0.0.0.0 \
MCP_OAUTH_REDIRECT_URI=http://192.168.1.20:8765/callback \
pnpm -w run cli "who am I?"
```

Set `MCP_OAUTH_REDIRECT_URI` whenever the address the browser uses differs from
the one the CLI binds to, such as behind port forwarding or a devcontainer.

### Direct Sentry Token Testing

Remote `/mcp` also accepts explicit upstream Sentry API tokens with a separate
Expand Down
22 changes: 18 additions & 4 deletions packages/mcp-test-client/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";

export interface OAuthClientConfig {
clientId: string;
mcpHost: string;
/** Redirect URI this client was registered with. */
redirectUri?: string;
registeredAt: string;
accessToken?: string;
tokenExpiresAt?: string;
Expand Down Expand Up @@ -66,22 +68,34 @@ export class ConfigManager {
* Get OAuth client ID for a specific MCP host
*/
async getOAuthClientId(mcpHost: string): Promise<string | null> {
const config = await this.loadConfig();
const clientConfig = config.oauthClients[mcpHost];
const clientConfig = await this.getOAuthClient(mcpHost);
return clientConfig?.clientId || null;
}

/**
* Get the full OAuth client config for a specific MCP host
*/
async getOAuthClient(mcpHost: string): Promise<OAuthClientConfig | null> {
const config = await this.loadConfig();
return config.oauthClients[mcpHost] || null;
}

/**
* Store OAuth client ID for a specific MCP host
*/
async setOAuthClientId(mcpHost: string, clientId: string): Promise<void> {
async setOAuthClientId(
mcpHost: string,
clientId: string,
redirectUri?: string,
): Promise<void> {
const config = await this.loadConfig();

// Preserve existing access token if present
const existing = config.oauthClients[mcpHost];
config.oauthClients[mcpHost] = {
clientId,
mcpHost,
redirectUri,
registeredAt: new Date().toISOString(),
accessToken: existing?.accessToken,
tokenExpiresAt: existing?.tokenExpiresAt,
Expand Down
111 changes: 110 additions & 1 deletion packages/mcp-test-client/src/auth/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import open from "open";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OAuthClient } from "./oauth.js";

vi.mock("open", () => ({
Expand All @@ -12,6 +12,11 @@ describe("OAuthClient", () => {
vi.spyOn(console, "log").mockImplementation(() => {});
});

afterEach(() => {
// biome-ignore lint/performance/noDelete: Required to properly unset environment variable
delete process.env.MCP_OAUTH_REDIRECT_URI;
});

it("includes the protected resource in the authorization request", async () => {
const client = new OAuthClient({
mcpHost: "https://mcp.sentry.dev/mcp/sentry/javascript",
Expand Down Expand Up @@ -50,4 +55,108 @@ describe("OAuthClient", () => {
"https://mcp.sentry.dev/mcp/sentry/javascript",
);
});

it("sends one redirect URI to registration, authorization, and token exchange", async () => {
const redirectUri = "http://192.168.1.20:8765/callback";
process.env.MCP_OAUTH_REDIRECT_URI = redirectUri;

const client = new OAuthClient({ mcpHost: "https://mcp.sentry.dev" });

const registerClient = vi
.spyOn(client as never, "registerClient")
.mockResolvedValue("client-123");
const exchangeCodeForToken = vi
.spyOn(client as never, "exchangeCodeForToken")
.mockResolvedValue({
access_token: "access-token",
token_type: "Bearer",
});
vi.spyOn(client as never, "startCallbackServer").mockResolvedValue({
waitForCallback: async () => ({
code: "auth-code",
state: "oauth-state",
}),
});
vi.spyOn(client as never, "generateState").mockReturnValue("oauth-state");
vi.spyOn(client as never, "generatePKCE").mockReturnValue({
verifier: "verifier",
challenge: "challenge",
});
vi.spyOn(
(client as never).configManager,
"getOAuthClient",
).mockResolvedValue(null);
vi.spyOn(
(client as never).configManager,
"setOAuthClientId",
).mockResolvedValue(undefined);
vi.spyOn(
(client as never).configManager,
"setAccessToken",
).mockResolvedValue(undefined);

await client.authenticate();

// Registration is what binds the URI server-side; the other two are
// validated against it and must match byte for byte.
expect(registerClient).toHaveBeenCalledTimes(1);
expect((client as never).redirect.redirectUri).toBe(redirectUri);
expect(
new URL(vi.mocked(open).mock.calls[0][0]).searchParams.get(
"redirect_uri",
),
).toBe(redirectUri);
expect(exchangeCodeForToken).toHaveBeenCalledTimes(1);
});

it("re-registers when the redirect URI no longer matches the stored client", async () => {
process.env.MCP_OAUTH_REDIRECT_URI = "http://192.168.1.20:8765/callback";

const client = new OAuthClient({ mcpHost: "https://mcp.sentry.dev" });

const registerClient = vi
.spyOn(client as never, "registerClient")
.mockResolvedValue("client-new");
vi.spyOn(
(client as never).configManager,
"getOAuthClient",
).mockResolvedValue({
clientId: "client-old",
mcpHost: "https://mcp.sentry.dev/mcp",
redirectUri: "http://localhost:8765/callback",
registeredAt: new Date().toISOString(),
});
const setOAuthClientId = vi
.spyOn((client as never).configManager, "setOAuthClientId")
.mockResolvedValue(undefined);

const clientId = await (client as never).getOrRegisterClientId();

expect(registerClient).toHaveBeenCalledTimes(1);
expect(clientId).toBe("client-new");
expect(setOAuthClientId).toHaveBeenCalledWith(
"https://mcp.sentry.dev/mcp",
"client-new",
"http://192.168.1.20:8765/callback",
);
});

it("reuses a client registered before the redirect URI was recorded", async () => {
const client = new OAuthClient({ mcpHost: "https://mcp.sentry.dev" });

const registerClient = vi.spyOn(client as never, "registerClient");
vi.spyOn(
(client as never).configManager,
"getOAuthClient",
).mockResolvedValue({
clientId: "client-legacy",
mcpHost: "https://mcp.sentry.dev/mcp",
registeredAt: new Date().toISOString(),
});

const clientId = await (client as never).getOrRegisterClientId();

expect(clientId).toBe("client-legacy");
expect(registerClient).not.toHaveBeenCalled();
});
});
58 changes: 40 additions & 18 deletions packages/mcp-test-client/src/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { randomBytes, createHash } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import { type Server, createServer } from "node:http";
import { URL } from "node:url";
import { createServer, type Server } from "node:http";
import open from "open";
import chalk from "chalk";
import {
OAUTH_REDIRECT_PORT,
OAUTH_REDIRECT_URI,
DEFAULT_OAUTH_SCOPES,
} from "../constants.js";
import { logInfo, logSuccess, logToolResult, logError } from "../logger.js";
import open from "open";
import { DEFAULT_OAUTH_SCOPES } from "../constants.js";
import { logError, logInfo, logSuccess, logToolResult } from "../logger.js";
import {
resolveAuthorizationServerUrl,
resolveProtectedResourceUrl,
} from "../mcp-url.js";
import { ConfigManager } from "./config.js";
import {
type OAuthRedirect,
defaultOAuthRedirectUri,
isLoopbackHost,
resolveOAuthRedirect,
} from "./redirect.js";

export interface OAuthConfig {
mcpHost: string;
Expand Down Expand Up @@ -44,13 +46,15 @@ export class OAuthClient {
private config: OAuthConfig;
private server: Server | null = null;
private configManager: ConfigManager;
private redirect: OAuthRedirect;

constructor(config: OAuthConfig) {
this.config = {
...config,
scopes: config.scopes || DEFAULT_OAUTH_SCOPES,
};
this.configManager = new ConfigManager();
this.redirect = resolveOAuthRedirect();
}

private getProtectedResourceUrl(): URL {
Expand Down Expand Up @@ -91,7 +95,7 @@ export class OAuthClient {
const registrationData = {
client_name: "Sentry MCP CLI",
client_uri: "https://github.com/getsentry/sentry-mcp",
redirect_uris: [OAUTH_REDIRECT_URI],
redirect_uris: [this.redirect.redirectUri],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none", // PKCE, no client secret
Expand Down Expand Up @@ -139,7 +143,7 @@ export class OAuthClient {
return;
}

const url = new URL(req.url, `http://localhost:${OAUTH_REDIRECT_PORT}`);
const url = new URL(req.url, `http://localhost:${this.redirect.port}`);

if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
Expand Down Expand Up @@ -214,7 +218,15 @@ export class OAuthClient {
}
});

this.server.listen(OAUTH_REDIRECT_PORT, "127.0.0.1", () => {
if (!isLoopbackHost(this.redirect.host)) {
logInfo(
chalk.yellow(
`Serving the OAuth callback on ${this.redirect.host}:${this.redirect.port}, reachable from the network`,
),
);
}

this.server.listen(this.redirect.port, this.redirect.host, () => {
const waitForCallback = () =>
new Promise<{ code: string; state: string }>((res, rej) => {
resolveCallback = res;
Expand Down Expand Up @@ -242,7 +254,7 @@ export class OAuthClient {
grant_type: "authorization_code",
client_id: params.clientId,
code: params.code,
redirect_uri: OAUTH_REDIRECT_URI,
redirect_uri: this.redirect.redirectUri,
code_verifier: params.codeVerifier,
});

Expand Down Expand Up @@ -270,10 +282,16 @@ export class OAuthClient {
private async getOrRegisterClientId(): Promise<string> {
const configKey = this.getConfigKey();

// Check if we already have a registered client for this host
let clientId = await this.configManager.getOAuthClientId(configKey);
// Check if we already have a registered client for this host. The OAuth
// server validates the request against the redirect URIs bound to the
// client, so a changed redirect URI needs a new registration. Clients
// registered before the URI was recorded used the default.
const existing = await this.configManager.getOAuthClient(configKey);
const registeredRedirectUri =
existing?.redirectUri ?? defaultOAuthRedirectUri();

if (clientId) {
let clientId = existing?.clientId ?? null;
if (clientId && registeredRedirectUri === this.redirect.redirectUri) {
return clientId;
}

Expand All @@ -283,7 +301,11 @@ export class OAuthClient {
clientId = await this.registerClient();

// Store the client ID for future use
await this.configManager.setOAuthClientId(configKey, clientId);
await this.configManager.setOAuthClientId(
configKey,
clientId,
this.redirect.redirectUri,
);

logSuccess("Client registered and saved");
logToolResult(clientId);
Expand Down Expand Up @@ -331,7 +353,7 @@ export class OAuthClient {
// Build authorization URL
const authUrl = new URL(this.getAuthorizationServerUrl("/oauth/authorize"));
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
authUrl.searchParams.set("redirect_uri", this.redirect.redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", this.config.scopes!.join(" "));
authUrl.searchParams.set("state", state);
Expand Down
Loading
Loading