Skip to content

Commit 9c3fee1

Browse files
authored
Merge branch 'main' into add-server-startup-logging
2 parents f980763 + 688752e commit 9c3fee1

File tree

13 files changed

+457
-74
lines changed

13 files changed

+457
-74
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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 tokens = await handleOAuthCallback(serverUrl, code);
28+
// Store both access and refresh tokens
29+
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token);
30+
if (tokens.refresh_token) {
31+
sessionStorage.setItem(
32+
SESSION_KEYS.REFRESH_TOKEN,
33+
tokens.refresh_token,
34+
);
35+
}
36+
// Redirect back to the main app with server URL to trigger auto-connect
37+
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
38+
} catch (error) {
39+
console.error("OAuth callback error:", error);
40+
window.location.href = "/";
41+
}
42+
};
43+
44+
void handleCallback();
45+
}, []);
46+
47+
return (
48+
<div className="flex items-center justify-center h-screen">
49+
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
50+
</div>
51+
);
52+
};
53+
54+
export default OAuthCallback;

client/src/components/ToolsTab.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,20 @@ const ToolsTab = ({
8787
className="max-w-full h-auto"
8888
/>
8989
)}
90-
{item.type === "resource" && (
91-
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
92-
{JSON.stringify(item.resource, null, 2)}
93-
</pre>
94-
)}
90+
{item.type === "resource" &&
91+
(item.resource?.mimeType?.startsWith("audio/") ? (
92+
<audio
93+
controls
94+
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
95+
className="w-full"
96+
>
97+
<p>Your browser does not support audio playback</p>
98+
</audio>
99+
) : (
100+
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
101+
{JSON.stringify(item.resource, null, 2)}
102+
</pre>
103+
))}
95104
</div>
96105
))}
97106
</>

client/src/lib/auth.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import pkceChallenge from "pkce-challenge";
2+
import { SESSION_KEYS } from "./constants";
3+
import { z } from "zod";
4+
5+
export const OAuthMetadataSchema = z.object({
6+
authorization_endpoint: z.string(),
7+
token_endpoint: z.string(),
8+
});
9+
10+
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
11+
12+
export const OAuthTokensSchema = z.object({
13+
access_token: z.string(),
14+
refresh_token: z.string().optional(),
15+
expires_in: z.number().optional(),
16+
});
17+
18+
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
19+
20+
export async function discoverOAuthMetadata(
21+
serverUrl: string,
22+
): Promise<OAuthMetadata> {
23+
try {
24+
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
25+
const response = await fetch(url.toString());
26+
27+
if (response.ok) {
28+
const metadata = await response.json();
29+
const validatedMetadata = OAuthMetadataSchema.parse({
30+
authorization_endpoint: metadata.authorization_endpoint,
31+
token_endpoint: metadata.token_endpoint,
32+
});
33+
return validatedMetadata;
34+
}
35+
} catch (error) {
36+
console.warn("OAuth metadata discovery failed:", error);
37+
}
38+
39+
// Fall back to default endpoints
40+
const baseUrl = new URL(serverUrl);
41+
const defaultMetadata = {
42+
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
43+
token_endpoint: new URL("/token", baseUrl).toString(),
44+
};
45+
return OAuthMetadataSchema.parse(defaultMetadata);
46+
}
47+
48+
export async function startOAuthFlow(serverUrl: string): Promise<string> {
49+
// Generate PKCE challenge
50+
const challenge = await pkceChallenge();
51+
const codeVerifier = challenge.code_verifier;
52+
const codeChallenge = challenge.code_challenge;
53+
54+
// Store code verifier for later use
55+
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
56+
57+
// Discover OAuth endpoints
58+
const metadata = await discoverOAuthMetadata(serverUrl);
59+
60+
// Build authorization URL
61+
const authUrl = new URL(metadata.authorization_endpoint);
62+
authUrl.searchParams.set("response_type", "code");
63+
authUrl.searchParams.set("code_challenge", codeChallenge);
64+
authUrl.searchParams.set("code_challenge_method", "S256");
65+
authUrl.searchParams.set(
66+
"redirect_uri",
67+
window.location.origin + "/oauth/callback",
68+
);
69+
70+
return authUrl.toString();
71+
}
72+
73+
export async function handleOAuthCallback(
74+
serverUrl: string,
75+
code: string,
76+
): Promise<OAuthTokens> {
77+
// Get stored code verifier
78+
const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
79+
if (!codeVerifier) {
80+
throw new Error("No code verifier found");
81+
}
82+
83+
// Discover OAuth endpoints
84+
const metadata = await discoverOAuthMetadata(serverUrl);
85+
// Exchange code for tokens
86+
const response = await fetch(metadata.token_endpoint, {
87+
method: "POST",
88+
headers: {
89+
"Content-Type": "application/json",
90+
},
91+
body: JSON.stringify({
92+
grant_type: "authorization_code",
93+
code,
94+
code_verifier: codeVerifier,
95+
redirect_uri: window.location.origin + "/oauth/callback",
96+
}),
97+
});
98+
99+
if (!response.ok) {
100+
throw new Error("Token exchange failed");
101+
}
102+
103+
const tokens = await response.json();
104+
return OAuthTokensSchema.parse(tokens);
105+
}
106+
107+
export async function refreshAccessToken(
108+
serverUrl: string,
109+
): Promise<OAuthTokens> {
110+
const refreshToken = sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN);
111+
if (!refreshToken) {
112+
throw new Error("No refresh token available");
113+
}
114+
115+
const metadata = await discoverOAuthMetadata(serverUrl);
116+
117+
const response = await fetch(metadata.token_endpoint, {
118+
method: "POST",
119+
headers: {
120+
"Content-Type": "application/json",
121+
},
122+
body: JSON.stringify({
123+
grant_type: "refresh_token",
124+
refresh_token: refreshToken,
125+
}),
126+
});
127+
128+
if (!response.ok) {
129+
throw new Error("Token refresh failed");
130+
}
131+
132+
const tokens = await response.json();
133+
return OAuthTokensSchema.parse(tokens);
134+
}

client/src/lib/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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+
REFRESH_TOKEN: "mcp_refresh_token",
7+
} as const;

0 commit comments

Comments
 (0)