Skip to content

Commit d45d8c9

Browse files
authored
Merge branch 'main' into update-readme
2 parents eba1538 + 7cad9a0 commit d45d8c9

File tree

6 files changed

+219
-50
lines changed

6 files changed

+219
-50
lines changed

client/src/App.tsx

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import {
1717
Tool,
1818
LoggingLevel,
1919
} from "@modelcontextprotocol/sdk/types.js";
20-
import React, { Suspense, useEffect, useRef, useState } from "react";
20+
import React, {
21+
Suspense,
22+
useCallback,
23+
useEffect,
24+
useRef,
25+
useState,
26+
} from "react";
2127
import { useConnection } from "./lib/hooks/useConnection";
2228
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
2329
import { StdErrNotification } from "./lib/notificationTypes";
@@ -46,14 +52,10 @@ import ToolsTab from "./components/ToolsTab";
4652
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
4753
import { InspectorConfig } from "./lib/configurationTypes";
4854
import { getMCPProxyAddress } from "./utils/configUtils";
49-
import { useToast } from "@/hooks/use-toast";
5055

51-
const params = new URLSearchParams(window.location.search);
5256
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
5357

5458
const App = () => {
55-
const { toast } = useToast();
56-
// Handle OAuth callback route
5759
const [resources, setResources] = useState<Resource[]>([]);
5860
const [resourceTemplates, setResourceTemplates] = useState<
5961
ResourceTemplate[]
@@ -221,31 +223,15 @@ const App = () => {
221223
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
222224
}, [config]);
223225

224-
const hasProcessedRef = useRef(false);
225-
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
226-
useEffect(() => {
227-
if (hasProcessedRef.current) {
228-
// Only try to connect once
229-
return;
230-
}
231-
const serverUrl = params.get("serverUrl");
232-
if (serverUrl) {
226+
// Auto-connect to previously saved serverURL after OAuth callback
227+
const onOAuthConnect = useCallback(
228+
(serverUrl: string) => {
233229
setSseUrl(serverUrl);
234230
setTransportType("sse");
235-
// Remove serverUrl from URL without reloading the page
236-
const newUrl = new URL(window.location.href);
237-
newUrl.searchParams.delete("serverUrl");
238-
window.history.replaceState({}, "", newUrl.toString());
239-
// Show success toast for OAuth
240-
toast({
241-
title: "Success",
242-
description: "Successfully authenticated with OAuth",
243-
});
244-
hasProcessedRef.current = true;
245-
// Connect to the server
246-
connectMcpServer();
247-
}
248-
}, [connectMcpServer, toast]);
231+
void connectMcpServer();
232+
},
233+
[connectMcpServer],
234+
);
249235

250236
useEffect(() => {
251237
fetch(`${getMCPProxyAddress(config)}/config`)
@@ -486,7 +472,7 @@ const App = () => {
486472
);
487473
return (
488474
<Suspense fallback={<div>Loading...</div>}>
489-
<OAuthCallback />
475+
<OAuthCallback onConnect={onOAuthConnect} />
490476
</Suspense>
491477
);
492478
}

client/src/components/OAuthCallback.tsx

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ import { useEffect, useRef } from "react";
22
import { InspectorOAuthClientProvider } from "../lib/auth";
33
import { SESSION_KEYS } from "../lib/constants";
44
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
5+
import { useToast } from "@/hooks/use-toast.ts";
6+
import {
7+
generateOAuthErrorDescription,
8+
parseOAuthCallbackParams,
9+
} from "@/utils/oauthUtils.ts";
510

6-
const OAuthCallback = () => {
11+
interface OAuthCallbackProps {
12+
onConnect: (serverUrl: string) => void;
13+
}
14+
15+
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
16+
const { toast } = useToast();
717
const hasProcessedRef = useRef(false);
818

919
useEffect(() => {
@@ -14,40 +24,56 @@ const OAuthCallback = () => {
1424
}
1525
hasProcessedRef.current = true;
1626

17-
const params = new URLSearchParams(window.location.search);
18-
const code = params.get("code");
19-
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
27+
const notifyError = (description: string) =>
28+
void toast({
29+
title: "OAuth Authorization Error",
30+
description,
31+
variant: "destructive",
32+
});
2033

21-
if (!code || !serverUrl) {
22-
console.error("Missing code or server URL");
23-
window.location.href = "/";
24-
return;
34+
const params = parseOAuthCallbackParams(window.location.search);
35+
if (!params.successful) {
36+
return notifyError(generateOAuthErrorDescription(params));
37+
}
38+
39+
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
40+
if (!serverUrl) {
41+
return notifyError("Missing Server URL");
2542
}
2643

44+
let result;
2745
try {
2846
// Create an auth provider with the current server URL
2947
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
3048

31-
const result = await auth(serverAuthProvider, {
49+
result = await auth(serverAuthProvider, {
3250
serverUrl,
33-
authorizationCode: code,
51+
authorizationCode: params.code,
3452
});
35-
if (result !== "AUTHORIZED") {
36-
throw new Error(
37-
`Expected to be authorized after providing auth code, got: ${result}`,
38-
);
39-
}
40-
41-
// Redirect back to the main app with server URL to trigger auto-connect
42-
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
4353
} catch (error) {
4454
console.error("OAuth callback error:", error);
45-
window.location.href = "/";
55+
return notifyError(`Unexpected error occurred: ${error}`);
4656
}
57+
58+
if (result !== "AUTHORIZED") {
59+
return notifyError(
60+
`Expected to be authorized after providing auth code, got: ${result}`,
61+
);
62+
}
63+
64+
// Finally, trigger auto-connect
65+
toast({
66+
title: "Success",
67+
description: "Successfully authenticated with OAuth",
68+
variant: "default",
69+
});
70+
onConnect(serverUrl);
4771
};
4872

49-
void handleCallback();
50-
}, []);
73+
handleCallback().finally(() => {
74+
window.history.replaceState({}, document.title, "/");
75+
});
76+
}, [toast, onConnect]);
5177

5278
return (
5379
<div className="flex items-center justify-center h-screen">

client/src/lib/auth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,16 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
8888

8989
return verifier;
9090
}
91+
92+
clear() {
93+
sessionStorage.removeItem(
94+
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
95+
);
96+
sessionStorage.removeItem(
97+
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
98+
);
99+
sessionStorage.removeItem(
100+
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
101+
);
102+
}
91103
}

client/src/lib/hooks/useConnection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ export function useConnection({
396396

397397
const disconnect = async () => {
398398
await mcpClient?.close();
399+
const authProvider = new InspectorOAuthClientProvider(sseUrl);
400+
authProvider.clear();
399401
setMcpClient(null);
400402
setConnectionStatus("disconnected");
401403
setCompletionsSupported(false);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
generateOAuthErrorDescription,
3+
parseOAuthCallbackParams,
4+
} from "@/utils/oauthUtils.ts";
5+
6+
describe("parseOAuthCallbackParams", () => {
7+
it("Returns successful: true and code when present", () => {
8+
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
9+
successful: true,
10+
code: "fake-code",
11+
});
12+
});
13+
it("Returns successful: false and error when error is present", () => {
14+
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
15+
successful: false,
16+
error: "access_denied",
17+
error_description: null,
18+
error_uri: null,
19+
});
20+
});
21+
it("Returns optional error metadata fields when present", () => {
22+
const search =
23+
"?error=access_denied&" +
24+
"error_description=User%20Denied%20Request&" +
25+
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
26+
expect(parseOAuthCallbackParams(search)).toEqual({
27+
successful: false,
28+
error: "access_denied",
29+
error_description: "User Denied Request",
30+
error_uri: "https://example.com/error-docs",
31+
});
32+
});
33+
it("Returns error when nothing present", () => {
34+
expect(parseOAuthCallbackParams("?")).toEqual({
35+
successful: false,
36+
error: "invalid_request",
37+
error_description: "Missing code or error in response",
38+
error_uri: null,
39+
});
40+
});
41+
});
42+
43+
describe("generateOAuthErrorDescription", () => {
44+
it("When only error is present", () => {
45+
expect(
46+
generateOAuthErrorDescription({
47+
successful: false,
48+
error: "invalid_request",
49+
error_description: null,
50+
error_uri: null,
51+
}),
52+
).toBe("Error: invalid_request.");
53+
});
54+
it("When error description is present", () => {
55+
expect(
56+
generateOAuthErrorDescription({
57+
successful: false,
58+
error: "invalid_request",
59+
error_description: "The request could not be completed as dialed",
60+
error_uri: null,
61+
}),
62+
).toEqual(
63+
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
64+
);
65+
});
66+
it("When all fields present", () => {
67+
expect(
68+
generateOAuthErrorDescription({
69+
successful: false,
70+
error: "invalid_request",
71+
error_description: "The request could not be completed as dialed",
72+
error_uri: "https://example.com/error-docs",
73+
}),
74+
).toEqual(
75+
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
76+
);
77+
});
78+
});

client/src/utils/oauthUtils.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// The parsed query parameters returned by the Authorization Server
2+
// representing either a valid authorization_code or an error
3+
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
4+
type CallbackParams =
5+
| {
6+
successful: true;
7+
// The authorization code is generated by the authorization server.
8+
code: string;
9+
}
10+
| {
11+
successful: false;
12+
// The OAuth 2.1 Error Code.
13+
// Usually one of:
14+
// ```
15+
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
16+
// invalid_scope, server_error, temporarily_unavailable
17+
// ```
18+
error: string;
19+
// Human-readable ASCII text providing additional information, used to assist the
20+
// developer in understanding the error that occurred.
21+
error_description: string | null;
22+
// A URI identifying a human-readable web page with information about the error,
23+
// used to provide the client developer with additional information about the error.
24+
error_uri: string | null;
25+
};
26+
27+
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
28+
const params = new URLSearchParams(location);
29+
30+
const code = params.get("code");
31+
if (code) {
32+
return { successful: true, code };
33+
}
34+
35+
const error = params.get("error");
36+
const error_description = params.get("error_description");
37+
const error_uri = params.get("error_uri");
38+
39+
if (error) {
40+
return { successful: false, error, error_description, error_uri };
41+
}
42+
43+
return {
44+
successful: false,
45+
error: "invalid_request",
46+
error_description: "Missing code or error in response",
47+
error_uri: null,
48+
};
49+
};
50+
51+
export const generateOAuthErrorDescription = (
52+
params: Extract<CallbackParams, { successful: false }>,
53+
): string => {
54+
const error = params.error;
55+
const errorDescription = params.error_description;
56+
const errorUri = params.error_uri;
57+
58+
return [
59+
`Error: ${error}.`,
60+
errorDescription ? `Details: ${errorDescription}.` : "",
61+
errorUri ? `More info: ${errorUri}.` : "",
62+
]
63+
.filter(Boolean)
64+
.join("\n");
65+
};

0 commit comments

Comments
 (0)