Skip to content

Commit f2ed9c9

Browse files
committed
feat: Make OAuth callback URIs configurable
This makes the MCP Inspector's OAuth 2.0 callback URIs configurable via environment variables (OAUTH_MCP_INSPECTOR_CALLBACK, OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK). This is useful for environments where the Inspector is running behind a proxy or in a containerized setup with a different public-facing URL. The server listens on the ports specified in these URLs and forwards OAuth2 authorization codes to the Inspector frontend.
1 parent 7460ea2 commit f2ed9c9

File tree

8 files changed

+284
-0
lines changed

8 files changed

+284
-0
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,37 @@ The MCP Inspector supports the following configuration settings. To change them,
198198

199199
These settings can be adjusted in real-time through the UI and will persist across sessions.
200200

201+
#### OAuth Callback Configuration
202+
203+
The MCP Inspector supports configurable OAuth callback URLs through environment variables. This allows you to run the inspector with custom OAuth callback endpoints that OAuth providers can redirect to:
204+
205+
| Environment Variable | Description | Default |
206+
| ------------------------------------ | --------------------------------------------------- | ----------------------------------------------- |
207+
| `OAUTH_MCP_INSPECTOR_CALLBACK` | OAuth callback URL for standard authentication flow | `{window.location.origin}/oauth/callback` |
208+
| `OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK` | OAuth callback URL for debug authentication flow | `{window.location.origin}/oauth/callback/debug` |
209+
210+
**How it works:**
211+
1. MCP Inspector automatically starts HTTP servers on the ports specified in your OAuth callback URLs
212+
2. When an OAuth provider redirects to your callback URL, these servers capture the authorization code
213+
3. The servers then redirect the browser to the MCP Inspector UI to complete the OAuth flow
214+
215+
**Example usage:**
216+
217+
```bash
218+
export OAUTH_MCP_INSPECTOR_CALLBACK="http://localhost:3060"
219+
export OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK="http://localhost:3061"
220+
npx @modelcontextprotocol/inspector
221+
```
222+
223+
**What happens:**
224+
- MCP Inspector starts on `http://localhost:6274` (default)
225+
- OAuth callback server starts on `http://localhost:3060`
226+
- OAuth debug callback server starts on `http://localhost:3061`
227+
- OAuth providers redirect to your configured URLs (3060/3061)
228+
- These servers capture the authorization code and redirect to MCP Inspector (6274)
229+
230+
**Note:** The OAuth callback URLs are configured at runtime, so no rebuild is required when changing them.
231+
201232
The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations:
202233

203234
```bash

client/src/App.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,36 @@ const App = () => {
231231
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
232232
}, [config]);
233233

234+
// Load OAuth configuration from server
235+
useEffect(() => {
236+
const loadOAuthConfig = async () => {
237+
try {
238+
const proxyUrl = getMCPProxyAddress(config);
239+
const authConfig = getMCPProxyAuthToken(config);
240+
const headers: Record<string, string> = {};
241+
242+
if (authConfig.token) {
243+
headers[authConfig.header] = `Bearer ${authConfig.token}`;
244+
}
245+
246+
const response = await fetch(`${proxyUrl}/config`, { headers });
247+
if (response.ok) {
248+
const serverConfig = await response.json();
249+
if (serverConfig.oauthCallback) {
250+
sessionStorage.setItem('OAUTH_MCP_INSPECTOR_CALLBACK', serverConfig.oauthCallback);
251+
}
252+
if (serverConfig.oauthDebugCallback) {
253+
sessionStorage.setItem('OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK', serverConfig.oauthDebugCallback);
254+
}
255+
}
256+
} catch (error) {
257+
// Silently fail - OAuth config is optional
258+
console.debug('Failed to load OAuth configuration:', error);
259+
}
260+
};
261+
loadOAuthConfig();
262+
}, [config]);
263+
234264
// Auto-connect to previously saved serverURL after OAuth callback
235265
const onOAuthConnect = useCallback(
236266
(serverUrl: string) => {

client/src/lib/auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
1616
}
1717

1818
get redirectUrl() {
19+
// Check for runtime configuration
20+
const configuredCallback = sessionStorage.getItem('OAUTH_MCP_INSPECTOR_CALLBACK');
21+
if (configuredCallback) {
22+
return configuredCallback;
23+
}
1924
return window.location.origin + "/oauth/callback";
2025
}
2126

@@ -108,6 +113,11 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
108113
// display in debug UI.
109114
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
110115
get redirectUrl(): string {
116+
// Check for runtime configuration
117+
const configuredDebugCallback = sessionStorage.getItem('OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK');
118+
if (configuredDebugCallback) {
119+
return configuredDebugCallback;
120+
}
111121
return `${window.location.origin}/oauth/callback/debug`;
112122
}
113123

client/src/utils/configUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ export const initializeInspectorConfig = (
141141

142142
// Ensure all config items have the latest labels/descriptions from defaults
143143
for (const [key, value] of Object.entries(baseConfig)) {
144+
// Skip if key doesn't exist in DEFAULT_INSPECTOR_CONFIG
145+
if (!(key in DEFAULT_INSPECTOR_CONFIG)) {
146+
delete baseConfig[key as keyof InspectorConfig];
147+
continue;
148+
}
149+
144150
baseConfig[key as keyof InspectorConfig] = {
145151
...value,
146152
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,

package-lock.json

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"zod": "^3.23.8"
5757
},
5858
"devDependencies": {
59+
"@modelcontextprotocol/inspector": "^0.15.0",
5960
"@playwright/test": "^1.52.0",
6061
"@types/jest": "^29.5.14",
6162
"@types/node": "^22.7.5",

server/src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import express from "express";
2020
import { findActualExecutable } from "spawn-rx";
2121
import mcpProxy from "./mcpProxy.js";
2222
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
23+
import { OAuthCallbackManager } from "./oauthCallbacks.js";
2324

2425
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
2526
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
@@ -522,6 +523,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
522523
defaultEnvironment,
523524
defaultCommand: values.env,
524525
defaultArgs: values.args,
526+
oauthCallback: process.env.OAUTH_MCP_INSPECTOR_CALLBACK || null,
527+
oauthDebugCallback: process.env.OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK || null,
525528
});
526529
} catch (error) {
527530
console.error("Error in /config route:", error);
@@ -534,6 +537,11 @@ const PORT = parseInt(
534537
10,
535538
);
536539
const HOST = process.env.HOST || "localhost";
540+
const CLIENT_PORT = process.env.CLIENT_PORT || "6274";
541+
542+
// Initialize OAuth callback manager
543+
const mcpInspectorUrl = `http://${HOST}:${CLIENT_PORT}`;
544+
const oauthCallbackManager = new OAuthCallbackManager(mcpInspectorUrl);
537545

538546
const server = app.listen(PORT, HOST);
539547
server.on("listening", () => {
@@ -548,6 +556,9 @@ server.on("listening", () => {
548556
`⚠️ WARNING: Authentication is disabled. This is not recommended.`,
549557
);
550558
}
559+
560+
// Start OAuth callback servers
561+
oauthCallbackManager.start();
551562
});
552563
server.on("error", (err) => {
553564
if (err.message.includes(`EADDRINUSE`)) {
@@ -557,3 +568,22 @@ server.on("error", (err) => {
557568
}
558569
process.exit(1);
559570
});
571+
572+
// Graceful shutdown
573+
process.on("SIGINT", () => {
574+
console.log("\nShutting down MCP Inspector...");
575+
oauthCallbackManager.stop();
576+
server.close(() => {
577+
console.log("Server closed");
578+
process.exit(0);
579+
});
580+
});
581+
582+
process.on("SIGTERM", () => {
583+
console.log("\nShutting down MCP Inspector...");
584+
oauthCallbackManager.stop();
585+
server.close(() => {
586+
console.log("Server closed");
587+
process.exit(0);
588+
});
589+
});

server/src/oauthCallbacks.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import http from "http";
2+
import { URL } from "url";
3+
4+
interface OAuthCallbackServer {
5+
server: http.Server;
6+
port: number;
7+
url: string;
8+
}
9+
10+
export class OAuthCallbackManager {
11+
private servers: OAuthCallbackServer[] = [];
12+
private mcpInspectorUrl: string;
13+
14+
constructor(mcpInspectorUrl: string) {
15+
this.mcpInspectorUrl = mcpInspectorUrl;
16+
}
17+
18+
private createCallbackServer(callbackUrl: string, isDebug: boolean = false): OAuthCallbackServer | null {
19+
try {
20+
const parsedUrl = new URL(callbackUrl);
21+
const port = parseInt(parsedUrl.port, 10);
22+
23+
if (!port || isNaN(port)) {
24+
console.warn(`Invalid port in OAuth callback URL: ${callbackUrl}`);
25+
return null;
26+
}
27+
28+
const server = http.createServer((req, res) => {
29+
const reqUrl = new URL(req.url || "", `http://${req.headers.host}`);
30+
31+
// Get OAuth parameters from the query string
32+
const code = reqUrl.searchParams.get("code");
33+
const state = reqUrl.searchParams.get("state");
34+
const error = reqUrl.searchParams.get("error");
35+
const errorDescription = reqUrl.searchParams.get("error_description");
36+
37+
// Build redirect URL to MCP Inspector
38+
const inspectorPath = isDebug ? "/oauth/callback/debug" : "/oauth/callback";
39+
const redirectUrl = new URL(inspectorPath, this.mcpInspectorUrl);
40+
41+
// Forward all query parameters
42+
reqUrl.searchParams.forEach((value, key) => {
43+
redirectUrl.searchParams.set(key, value);
44+
});
45+
46+
// Send redirect response
47+
res.writeHead(302, {
48+
"Location": redirectUrl.toString(),
49+
"Content-Type": "text/html",
50+
});
51+
52+
const redirectHtml = `
53+
<!DOCTYPE html>
54+
<html>
55+
<head>
56+
<title>OAuth Redirect</title>
57+
<meta charset="utf-8">
58+
</head>
59+
<body>
60+
<h2>OAuth Authentication</h2>
61+
<p>Redirecting to MCP Inspector...</p>
62+
<p>If you are not redirected automatically, <a href="${redirectUrl.toString()}">click here</a>.</p>
63+
<script>
64+
// Automatic redirect
65+
window.location.href = "${redirectUrl.toString()}";
66+
</script>
67+
</body>
68+
</html>
69+
`;
70+
71+
res.end(redirectHtml);
72+
73+
console.log(`OAuth ${isDebug ? "debug " : ""}callback received on port ${port}`);
74+
if (code) {
75+
console.log(` Authorization code: ${code.substring(0, 10)}...`);
76+
}
77+
if (error) {
78+
console.log(` Error: ${error} - ${errorDescription || "No description"}`);
79+
}
80+
console.log(` Redirecting to: ${redirectUrl.toString()}`);
81+
});
82+
83+
return { server, port, url: callbackUrl };
84+
} catch (error) {
85+
console.error(`Failed to create OAuth callback server for ${callbackUrl}:`, error);
86+
return null;
87+
}
88+
}
89+
90+
start(): void {
91+
const oauthCallback = process.env.OAUTH_MCP_INSPECTOR_CALLBACK;
92+
const oauthDebugCallback = process.env.OAUTH_MCP_INSPECTOR_DEBUG_CALLBACK;
93+
94+
if (oauthCallback) {
95+
const callbackServer = this.createCallbackServer(oauthCallback, false);
96+
if (callbackServer) {
97+
callbackServer.server.listen(callbackServer.port, () => {
98+
console.log(`🔗 OAuth callback server listening on ${callbackServer.url}`);
99+
});
100+
101+
callbackServer.server.on("error", (err) => {
102+
if ((err as any).code === "EADDRINUSE") {
103+
console.warn(`⚠️ OAuth callback port ${callbackServer.port} is in use`);
104+
} else {
105+
console.error(`OAuth callback server error:`, err);
106+
}
107+
});
108+
109+
this.servers.push(callbackServer);
110+
}
111+
}
112+
113+
if (oauthDebugCallback) {
114+
const debugCallbackServer = this.createCallbackServer(oauthDebugCallback, true);
115+
if (debugCallbackServer) {
116+
debugCallbackServer.server.listen(debugCallbackServer.port, () => {
117+
console.log(`🔗 OAuth debug callback server listening on ${debugCallbackServer.url}`);
118+
});
119+
120+
debugCallbackServer.server.on("error", (err) => {
121+
if ((err as any).code === "EADDRINUSE") {
122+
console.warn(`⚠️ OAuth debug callback port ${debugCallbackServer.port} is in use`);
123+
} else {
124+
console.error(`OAuth debug callback server error:`, err);
125+
}
126+
});
127+
128+
this.servers.push(debugCallbackServer);
129+
}
130+
}
131+
132+
if (this.servers.length === 0) {
133+
console.log("No OAuth callback URLs configured");
134+
}
135+
}
136+
137+
stop(): void {
138+
this.servers.forEach(({ server, port }) => {
139+
server.close(() => {
140+
console.log(`OAuth callback server on port ${port} stopped`);
141+
});
142+
});
143+
this.servers = [];
144+
}
145+
}

0 commit comments

Comments
 (0)