Skip to content

Commit ad004bc

Browse files
authored
Merge branch 'main' into bugfix/issue-114
2 parents db64943 + 7b3dff6 commit ad004bc

File tree

14 files changed

+480
-85
lines changed

14 files changed

+480
-85
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
1111
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
1212

1313
```bash
14-
npx @modelcontextprotocol/inspector build/index.js
14+
npx @modelcontextprotocol/inspector node build/index.js
1515
```
1616

1717
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
@@ -21,19 +21,19 @@ You can pass both arguments and environment variables to your MCP server. Argume
2121
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
2222

2323
# Pass environment variables only
24-
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js
24+
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
2525

2626
# Pass both environment variables and arguments
27-
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2
27+
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
2828

2929
# Use -- to separate inspector flags from server arguments
30-
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag
30+
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
3131
```
3232

3333
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
3434

3535
```bash
36-
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
36+
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
3737
```
3838

3939
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).

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.1",
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)