Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b53a83
Bump SDK
jspahrsummers Jan 23, 2025
60b8892
Pre-emptively bump npm package versions
jspahrsummers Jan 23, 2025
c1e06c4
Server doesn't need to inject `eventsource` anymore
jspahrsummers Jan 23, 2025
e7697eb
Pass through `Authorization` headers sent to inspector server
jspahrsummers Jan 23, 2025
14db05c
Clarify inspector-server error logging
jspahrsummers Jan 23, 2025
8bb5308
Report SSE 401 errors to the client
jspahrsummers Jan 24, 2025
8a20f77
Use new `SseError` class from SDK
jspahrsummers Jan 24, 2025
1c4ad60
Redirect into OAuth flow upon receiving 401
jspahrsummers Jan 24, 2025
16cb596
OAuth callback handler (not yet attached)
jspahrsummers Jan 24, 2025
23f89e4
Implement OAuth callback
jspahrsummers Jan 24, 2025
02cfb47
Extract session storage keys into constants
jspahrsummers Jan 24, 2025
e470eb5
Fix React import
jspahrsummers Jan 24, 2025
874320e
Token exchange body needs to be JSON
jspahrsummers Jan 24, 2025
af88770
Set Authorization header from client
jspahrsummers Jan 24, 2025
731ee58
Fix Authorization header passthrough
jspahrsummers Jan 24, 2025
a6d22cf
Bump SDK version
jspahrsummers Jan 24, 2025
3bc776f
Fix Vite config
jspahrsummers Jan 24, 2025
99d7592
Fix error state being briefly shown before OAuth
jspahrsummers Jan 24, 2025
c22f918
Remember last selected transport and SSE URL
jspahrsummers Jan 24, 2025
0648ba4
Auto-reconnect after OAuth
jspahrsummers Jan 24, 2025
51ea4bc
Add toast when OAuth succeeds
jspahrsummers Jan 24, 2025
fce6644
Fix double fetching
jspahrsummers Jan 24, 2025
0882a3e
Formatting
jspahrsummers Jan 24, 2025
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
5 changes: 3 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.3.0",
"version": "0.4.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
Expand All @@ -21,7 +21,7 @@
"preview": "vite preview"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"@modelcontextprotocol/sdk": "^1.4.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
Expand All @@ -30,6 +30,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.447.0",
"pkce-challenge": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-toastify": "^10.0.6",
Expand Down
55 changes: 49 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { useConnection } from "./lib/hooks/useConnection";
import {
ClientRequest,
CompatibilityCallToolResult,
Expand All @@ -10,15 +8,17 @@ import {
ListPromptsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
Resource,
ResourceTemplate,
Root,
ServerNotification,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { useEffect, useRef, useState } from "react";
import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";

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

Expand All @@ -32,6 +32,7 @@ import {
MessageSquare,
} from "lucide-react";

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

const App = () => {
// Handle OAuth callback route
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
Expand All @@ -71,8 +83,14 @@ const App = () => {
return localStorage.getItem("lastArgs") || "";
});

const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
);
});
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
Expand Down Expand Up @@ -190,6 +208,31 @@ const App = () => {
localStorage.setItem("lastArgs", args);
}, [args]);

useEffect(() => {
localStorage.setItem("lastSseUrl", sseUrl);
}, [sseUrl]);

useEffect(() => {
localStorage.setItem("lastTransportType", transportType);
}, [transportType]);

// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
const serverUrl = params.get("serverUrl");
if (serverUrl) {
setSseUrl(serverUrl);
setTransportType("sse");
// Remove serverUrl from URL without reloading the page
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth
toast.success("Successfully authenticated with OAuth");
// Connect to the server
connectMcpServer();
}
}, []);

useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`)
.then((response) => response.json())
Expand Down
48 changes: 48 additions & 0 deletions client/src/components/OAuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useRef } from "react";
import { handleOAuthCallback } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";

const OAuthCallback = () => {
const hasProcessedRef = useRef(false);

useEffect(() => {
const handleCallback = async () => {
// Skip if we've already processed this callback
if (hasProcessedRef.current) {
return;
}
hasProcessedRef.current = true;

const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);

if (!code || !serverUrl) {
console.error("Missing code or server URL");
window.location.href = "/";
return;
}

try {
const accessToken = await handleOAuthCallback(serverUrl, code);
// Store the access token for future use
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken);
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
} catch (error) {
console.error("OAuth callback error:", error);
window.location.href = "/";
}
};

void handleCallback();
}, []);

return (
<div className="flex items-center justify-center h-screen">
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
</div>
);
};

export default OAuthCallback;
93 changes: 93 additions & 0 deletions client/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import pkceChallenge from "pkce-challenge";
import { SESSION_KEYS } from "./constants";

export interface OAuthMetadata {
authorization_endpoint: string;
token_endpoint: string;
}

export async function discoverOAuthMetadata(
serverUrl: string,
): Promise<OAuthMetadata> {
try {
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
const response = await fetch(url.toString());

if (response.ok) {
const metadata = await response.json();
return {
authorization_endpoint: metadata.authorization_endpoint,
token_endpoint: metadata.token_endpoint,
};
}
} catch (error) {
console.warn("OAuth metadata discovery failed:", error);
}

// Fall back to default endpoints
const baseUrl = new URL(serverUrl);
return {
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
token_endpoint: new URL("/token", baseUrl).toString(),
};
}

export async function startOAuthFlow(serverUrl: string): Promise<string> {
// Generate PKCE challenge
const challenge = await pkceChallenge();
const codeVerifier = challenge.code_verifier;
const codeChallenge = challenge.code_challenge;

// Store code verifier for later use
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);

// Discover OAuth endpoints
const metadata = await discoverOAuthMetadata(serverUrl);

// Build authorization URL
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set(
"redirect_uri",
window.location.origin + "/oauth/callback",
);

return authUrl.toString();
}

export async function handleOAuthCallback(
serverUrl: string,
code: string,
): Promise<string> {
// Get stored code verifier
const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
if (!codeVerifier) {
throw new Error("No code verifier found");
}

// Discover OAuth endpoints
const metadata = await discoverOAuthMetadata(serverUrl);

// Exchange code for tokens
const response = await fetch(metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
code_verifier: codeVerifier,
redirect_uri: window.location.origin + "/oauth/callback",
}),
});

if (!response.ok) {
throw new Error("Token exchange failed");
}

const data = await response.json();
return data.access_token;
}
6 changes: 6 additions & 0 deletions client/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// OAuth-related session storage keys
export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
ACCESS_TOKEN: "mcp_access_token",
} as const;
39 changes: 35 additions & 4 deletions client/src/lib/hooks/useConnection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
Expand All @@ -12,8 +15,10 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { z } from "zod";
import { startOAuthFlow } from "../auth";
import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";

const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;

Expand Down Expand Up @@ -144,7 +149,20 @@ export function useConnection({
backendUrl.searchParams.append("url", sseUrl);
}

const clientTransport = new SSEClientTransport(backendUrl);
const headers: HeadersInit = {};
const accessToken = sessionStorage.getItem(SESSION_KEYS.ACCESS_TOKEN);
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}

const clientTransport = new SSEClientTransport(backendUrl, {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
});

if (onNotification) {
client.setNotificationHandler(
Expand All @@ -160,7 +178,20 @@ export function useConnection({
);
}

await client.connect(clientTransport);
try {
await client.connect(clientTransport);
} catch (error) {
console.error("Failed to connect to MCP server:", error);
if (error instanceof SseError && error.code === 401) {
// Store the server URL for the callback handler
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
const redirectUrl = await startOAuthFlow(sseUrl);
window.location.href = redirectUrl;
return;
}

throw error;
}

const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
Expand Down
3 changes: 2 additions & 1 deletion client/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
Expand Down
Loading
Loading