Skip to content

Commit 1d4e888

Browse files
Merge pull request #131 from modelcontextprotocol/justin/sse-auth
OAuth support for SSE
2 parents 361f9d1 + 0882a3e commit 1d4e888

File tree

12 files changed

+351
-68
lines changed

12 files changed

+351
-68
lines changed

client/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/inspector-client",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "Client-side application for the Model Context Protocol inspector",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -21,7 +21,7 @@
2121
"preview": "vite preview"
2222
},
2323
"dependencies": {
24-
"@modelcontextprotocol/sdk": "^1.0.3",
24+
"@modelcontextprotocol/sdk": "^1.4.1",
2525
"@radix-ui/react-icons": "^1.3.0",
2626
"@radix-ui/react-label": "^2.1.0",
2727
"@radix-ui/react-select": "^2.1.2",
@@ -30,6 +30,7 @@
3030
"class-variance-authority": "^0.7.0",
3131
"clsx": "^2.1.1",
3232
"lucide-react": "^0.447.0",
33+
"pkce-challenge": "^4.1.0",
3334
"react": "^18.3.1",
3435
"react-dom": "^18.3.1",
3536
"react-toastify": "^10.0.6",

client/src/App.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
2-
import { useConnection } from "./lib/hooks/useConnection";
31
import {
42
ClientRequest,
53
CompatibilityCallToolResult,
@@ -10,15 +8,17 @@ import {
108
ListPromptsResultSchema,
119
ListResourcesResultSchema,
1210
ListResourceTemplatesResultSchema,
13-
ReadResourceResultSchema,
1411
ListToolsResultSchema,
12+
ReadResourceResultSchema,
1513
Resource,
1614
ResourceTemplate,
1715
Root,
1816
ServerNotification,
1917
Tool,
2018
} from "@modelcontextprotocol/sdk/types.js";
21-
import { useEffect, useRef, useState } from "react";
19+
import React, { Suspense, useEffect, useRef, useState } from "react";
20+
import { useConnection } from "./lib/hooks/useConnection";
21+
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
2222

2323
import { StdErrNotification } from "./lib/notificationTypes";
2424

@@ -32,6 +32,7 @@ import {
3232
MessageSquare,
3333
} from "lucide-react";
3434

35+
import { toast } from "react-toastify";
3536
import { z } from "zod";
3637
import "./App.css";
3738
import ConsoleTab from "./components/ConsoleTab";
@@ -49,6 +50,17 @@ const PROXY_PORT = params.get("proxyPort") ?? "3000";
4950
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
5051

5152
const App = () => {
53+
// Handle OAuth callback route
54+
if (window.location.pathname === "/oauth/callback") {
55+
const OAuthCallback = React.lazy(
56+
() => import("./components/OAuthCallback"),
57+
);
58+
return (
59+
<Suspense fallback={<div>Loading...</div>}>
60+
<OAuthCallback />
61+
</Suspense>
62+
);
63+
}
5264
const [resources, setResources] = useState<Resource[]>([]);
5365
const [resourceTemplates, setResourceTemplates] = useState<
5466
ResourceTemplate[]
@@ -71,8 +83,14 @@ const App = () => {
7183
return localStorage.getItem("lastArgs") || "";
7284
});
7385

74-
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
75-
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
86+
const [sseUrl, setSseUrl] = useState<string>(() => {
87+
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
88+
});
89+
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
90+
return (
91+
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
92+
);
93+
});
7694
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
7795
const [stdErrNotifications, setStdErrNotifications] = useState<
7896
StdErrNotification[]
@@ -190,6 +208,31 @@ const App = () => {
190208
localStorage.setItem("lastArgs", args);
191209
}, [args]);
192210

211+
useEffect(() => {
212+
localStorage.setItem("lastSseUrl", sseUrl);
213+
}, [sseUrl]);
214+
215+
useEffect(() => {
216+
localStorage.setItem("lastTransportType", transportType);
217+
}, [transportType]);
218+
219+
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
220+
useEffect(() => {
221+
const serverUrl = params.get("serverUrl");
222+
if (serverUrl) {
223+
setSseUrl(serverUrl);
224+
setTransportType("sse");
225+
// Remove serverUrl from URL without reloading the page
226+
const newUrl = new URL(window.location.href);
227+
newUrl.searchParams.delete("serverUrl");
228+
window.history.replaceState({}, "", newUrl.toString());
229+
// Show success toast for OAuth
230+
toast.success("Successfully authenticated with OAuth");
231+
// Connect to the server
232+
connectMcpServer();
233+
}
234+
}, []);
235+
193236
useEffect(() => {
194237
fetch(`${PROXY_SERVER_URL}/config`)
195238
.then((response) => response.json())
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect, useRef } from "react";
2+
import { handleOAuthCallback } from "../lib/auth";
3+
import { SESSION_KEYS } from "../lib/constants";
4+
5+
const OAuthCallback = () => {
6+
const hasProcessedRef = useRef(false);
7+
8+
useEffect(() => {
9+
const handleCallback = async () => {
10+
// Skip if we've already processed this callback
11+
if (hasProcessedRef.current) {
12+
return;
13+
}
14+
hasProcessedRef.current = true;
15+
16+
const params = new URLSearchParams(window.location.search);
17+
const code = params.get("code");
18+
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
19+
20+
if (!code || !serverUrl) {
21+
console.error("Missing code or server URL");
22+
window.location.href = "/";
23+
return;
24+
}
25+
26+
try {
27+
const accessToken = await handleOAuthCallback(serverUrl, code);
28+
// Store the access token for future use
29+
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken);
30+
// Redirect back to the main app with server URL to trigger auto-connect
31+
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
32+
} catch (error) {
33+
console.error("OAuth callback error:", error);
34+
window.location.href = "/";
35+
}
36+
};
37+
38+
void handleCallback();
39+
}, []);
40+
41+
return (
42+
<div className="flex items-center justify-center h-screen">
43+
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
44+
</div>
45+
);
46+
};
47+
48+
export default OAuthCallback;

client/src/lib/auth.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import pkceChallenge from "pkce-challenge";
2+
import { SESSION_KEYS } from "./constants";
3+
4+
export interface OAuthMetadata {
5+
authorization_endpoint: string;
6+
token_endpoint: string;
7+
}
8+
9+
export async function discoverOAuthMetadata(
10+
serverUrl: string,
11+
): Promise<OAuthMetadata> {
12+
try {
13+
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
14+
const response = await fetch(url.toString());
15+
16+
if (response.ok) {
17+
const metadata = await response.json();
18+
return {
19+
authorization_endpoint: metadata.authorization_endpoint,
20+
token_endpoint: metadata.token_endpoint,
21+
};
22+
}
23+
} catch (error) {
24+
console.warn("OAuth metadata discovery failed:", error);
25+
}
26+
27+
// Fall back to default endpoints
28+
const baseUrl = new URL(serverUrl);
29+
return {
30+
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
31+
token_endpoint: new URL("/token", baseUrl).toString(),
32+
};
33+
}
34+
35+
export async function startOAuthFlow(serverUrl: string): Promise<string> {
36+
// Generate PKCE challenge
37+
const challenge = await pkceChallenge();
38+
const codeVerifier = challenge.code_verifier;
39+
const codeChallenge = challenge.code_challenge;
40+
41+
// Store code verifier for later use
42+
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
43+
44+
// Discover OAuth endpoints
45+
const metadata = await discoverOAuthMetadata(serverUrl);
46+
47+
// Build authorization URL
48+
const authUrl = new URL(metadata.authorization_endpoint);
49+
authUrl.searchParams.set("response_type", "code");
50+
authUrl.searchParams.set("code_challenge", codeChallenge);
51+
authUrl.searchParams.set("code_challenge_method", "S256");
52+
authUrl.searchParams.set(
53+
"redirect_uri",
54+
window.location.origin + "/oauth/callback",
55+
);
56+
57+
return authUrl.toString();
58+
}
59+
60+
export async function handleOAuthCallback(
61+
serverUrl: string,
62+
code: string,
63+
): Promise<string> {
64+
// Get stored code verifier
65+
const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
66+
if (!codeVerifier) {
67+
throw new Error("No code verifier found");
68+
}
69+
70+
// Discover OAuth endpoints
71+
const metadata = await discoverOAuthMetadata(serverUrl);
72+
73+
// Exchange code for tokens
74+
const response = await fetch(metadata.token_endpoint, {
75+
method: "POST",
76+
headers: {
77+
"Content-Type": "application/json",
78+
},
79+
body: JSON.stringify({
80+
grant_type: "authorization_code",
81+
code,
82+
code_verifier: codeVerifier,
83+
redirect_uri: window.location.origin + "/oauth/callback",
84+
}),
85+
});
86+
87+
if (!response.ok) {
88+
throw new Error("Token exchange failed");
89+
}
90+
91+
const data = await response.json();
92+
return data.access_token;
93+
}

client/src/lib/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// OAuth-related session storage keys
2+
export const SESSION_KEYS = {
3+
CODE_VERIFIER: "mcp_code_verifier",
4+
SERVER_URL: "mcp_server_url",
5+
ACCESS_TOKEN: "mcp_access_token",
6+
} as const;

client/src/lib/hooks/useConnection.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
2+
import {
3+
SSEClientTransport,
4+
SseError,
5+
} from "@modelcontextprotocol/sdk/client/sse.js";
36
import {
47
ClientNotification,
58
ClientRequest,
@@ -12,8 +15,10 @@ import {
1215
} from "@modelcontextprotocol/sdk/types.js";
1316
import { useState } from "react";
1417
import { toast } from "react-toastify";
15-
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
1618
import { z } from "zod";
19+
import { startOAuthFlow } from "../auth";
20+
import { SESSION_KEYS } from "../constants";
21+
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
1722

1823
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
1924

@@ -144,7 +149,20 @@ export function useConnection({
144149
backendUrl.searchParams.append("url", sseUrl);
145150
}
146151

147-
const clientTransport = new SSEClientTransport(backendUrl);
152+
const headers: HeadersInit = {};
153+
const accessToken = sessionStorage.getItem(SESSION_KEYS.ACCESS_TOKEN);
154+
if (accessToken) {
155+
headers["Authorization"] = `Bearer ${accessToken}`;
156+
}
157+
158+
const clientTransport = new SSEClientTransport(backendUrl, {
159+
eventSourceInit: {
160+
fetch: (url, init) => fetch(url, { ...init, headers }),
161+
},
162+
requestInit: {
163+
headers,
164+
},
165+
});
148166

149167
if (onNotification) {
150168
client.setNotificationHandler(
@@ -160,7 +178,20 @@ export function useConnection({
160178
);
161179
}
162180

163-
await client.connect(clientTransport);
181+
try {
182+
await client.connect(clientTransport);
183+
} catch (error) {
184+
console.error("Failed to connect to MCP server:", error);
185+
if (error instanceof SseError && error.code === 401) {
186+
// Store the server URL for the callback handler
187+
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
188+
const redirectUrl = await startOAuthFlow(sseUrl);
189+
window.location.href = redirectUrl;
190+
return;
191+
}
192+
193+
throw error;
194+
}
164195

165196
const capabilities = client.getServerCapabilities();
166197
setServerCapabilities(capabilities ?? null);

client/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import react from "@vitejs/plugin-react";
12
import path from "path";
23
import { defineConfig } from "vite";
3-
import react from "@vitejs/plugin-react";
44

55
// https://vitejs.dev/config/
66
export default defineConfig({
77
plugins: [react()],
8+
server: {},
89
resolve: {
910
alias: {
1011
"@": path.resolve(__dirname, "./src"),

0 commit comments

Comments
 (0)