shreyask commited on
Commit
83c3226
·
verified ·
1 Parent(s): 4513a96

feat: implement BrowserOAuthProvider for handling OAuth flows and enhance MCPClientService with OAuth support

Browse files
src/App.tsx CHANGED
@@ -145,6 +145,21 @@ function renderMarkdown(text: string): string {
145
  return DOMPurify.sanitize(marked.parse(text) as string);
146
  }
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  const App: React.FC = () => {
149
  const [systemPrompt, setSystemPrompt] = useState<string>(
150
  DEFAULT_SYSTEM_PROMPT
@@ -557,7 +572,19 @@ const App: React.FC = () => {
557
 
558
  let parsedResult;
559
  try {
560
- parsedResult = JSON.parse(result);
 
 
 
 
 
 
 
 
 
 
 
 
561
  } catch {
562
  parsedResult = result;
563
  }
@@ -659,13 +686,24 @@ const App: React.FC = () => {
659
  const toolCallContent = extractToolCallContent(response);
660
 
661
  if (toolCallContent) {
662
- const toolResults = await executeToolCalls(toolCallContent);
 
 
 
 
663
 
664
- const toolMessage: ToolMessage = {
665
- role: "tool",
666
- content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
667
- renderInfo: toolResults,
668
- };
 
 
 
 
 
 
 
669
  currentMessages.push(toolMessage);
670
  setMessages([...currentMessages]);
671
  continue;
@@ -852,13 +890,24 @@ const App: React.FC = () => {
852
  const toolCallContent = extractToolCallContent(response);
853
 
854
  if (toolCallContent) {
855
- const toolResults = await executeToolCalls(toolCallContent);
 
 
 
 
856
 
857
- const toolMessage: ToolMessage = {
858
- role: "tool",
859
- content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
860
- renderInfo: toolResults,
861
- };
 
 
 
 
 
 
 
862
  currentMessages.push(toolMessage);
863
  setMessages([...currentMessages]);
864
  continue;
 
145
  return DOMPurify.sanitize(marked.parse(text) as string);
146
  }
147
 
148
+ const safeStringifyToolResults = (results: any[]): string => {
149
+ try {
150
+ const stringified = JSON.stringify(results);
151
+ const MAX_SIZE = 5 * 1024 * 1024;
152
+ if (stringified.length > MAX_SIZE) {
153
+ console.warn(`Tool result is ${(stringified.length / 1024 / 1024).toFixed(2)}MB, truncating...`);
154
+ return stringified.substring(0, MAX_SIZE) + '\n\n...[TRUNCATED]';
155
+ }
156
+ return stringified;
157
+ } catch (error) {
158
+ console.error("Failed to stringify tool results:", error);
159
+ return JSON.stringify([{ error: "Failed to serialize tool results" }]);
160
+ }
161
+ };
162
+
163
  const App: React.FC = () => {
164
  const [systemPrompt, setSystemPrompt] = useState<string>(
165
  DEFAULT_SYSTEM_PROMPT
 
572
 
573
  let parsedResult;
574
  try {
575
+ if (result.length > 100000) {
576
+ parsedResult = await new Promise((resolve) => {
577
+ setTimeout(() => {
578
+ try {
579
+ resolve(JSON.parse(result));
580
+ } catch {
581
+ resolve(result);
582
+ }
583
+ }, 0);
584
+ });
585
+ } else {
586
+ parsedResult = JSON.parse(result);
587
+ }
588
  } catch {
589
  parsedResult = result;
590
  }
 
686
  const toolCallContent = extractToolCallContent(response);
687
 
688
  if (toolCallContent) {
689
+ currentMessages.push({
690
+ role: "assistant",
691
+ content: "_Processing tool results..._"
692
+ });
693
+ setMessages([...currentMessages]);
694
 
695
+ const toolResults = await executeToolCalls(toolCallContent);
696
+ currentMessages.pop();
697
+
698
+ const toolMessage: ToolMessage = await new Promise((resolve) => {
699
+ setTimeout(() => {
700
+ resolve({
701
+ role: "tool" as const,
702
+ content: safeStringifyToolResults(toolResults.map((r) => r.result ?? null)),
703
+ renderInfo: toolResults,
704
+ });
705
+ }, 0);
706
+ });
707
  currentMessages.push(toolMessage);
708
  setMessages([...currentMessages]);
709
  continue;
 
890
  const toolCallContent = extractToolCallContent(response);
891
 
892
  if (toolCallContent) {
893
+ currentMessages.push({
894
+ role: "assistant",
895
+ content: "_Processing tool results..._"
896
+ });
897
+ setMessages([...currentMessages]);
898
 
899
+ const toolResults = await executeToolCalls(toolCallContent);
900
+ currentMessages.pop();
901
+
902
+ const toolMessage: ToolMessage = await new Promise((resolve) => {
903
+ setTimeout(() => {
904
+ resolve({
905
+ role: "tool" as const,
906
+ content: safeStringifyToolResults(toolResults.map((r) => r.result ?? null)),
907
+ renderInfo: toolResults,
908
+ });
909
+ }, 0);
910
+ });
911
  currentMessages.push(toolMessage);
912
  setMessages([...currentMessages]);
913
  continue;
src/config/constants.ts CHANGED
@@ -19,7 +19,9 @@ export const STORAGE_KEYS = {
19
  OAUTH_REDIRECT_URI: "oauth_redirect_uri",
20
  OAUTH_RESOURCE: "oauth_resource",
21
  OAUTH_ACCESS_TOKEN: "oauth_access_token",
 
22
  OAUTH_CODE_VERIFIER: "oauth_code_verifier",
 
23
  OAUTH_MCP_SERVER_URL: "oauth_mcp_server_url",
24
  OAUTH_AUTHORIZATION_SERVER_METADATA: "oauth_authorization_server_metadata",
25
  MCP_SERVER_NAME: "mcp_server_name",
 
19
  OAUTH_REDIRECT_URI: "oauth_redirect_uri",
20
  OAUTH_RESOURCE: "oauth_resource",
21
  OAUTH_ACCESS_TOKEN: "oauth_access_token",
22
+ OAUTH_REFRESH_TOKEN: "oauth_refresh_token",
23
  OAUTH_CODE_VERIFIER: "oauth_code_verifier",
24
+ OAUTH_STATE: "oauth_state",
25
  OAUTH_MCP_SERVER_URL: "oauth_mcp_server_url",
26
  OAUTH_AUTHORIZATION_SERVER_METADATA: "oauth_authorization_server_metadata",
27
  MCP_SERVER_NAME: "mcp_server_name",
src/services/BrowserOAuthProvider.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser-based OAuth Client Provider for MCP
3
+ *
4
+ * Implements the OAuthClientProvider interface from @modelcontextprotocol/sdk
5
+ * to handle user-facing OAuth flows in a browser environment.
6
+ */
7
+
8
+ import type {
9
+ OAuthClientMetadata,
10
+ OAuthClientInformationMixed,
11
+ OAuthTokens,
12
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
13
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
14
+ import { secureStorage } from "../utils/storage";
15
+ import { STORAGE_KEYS } from "../config/constants";
16
+
17
+ export interface BrowserOAuthProviderOptions {
18
+ /**
19
+ * The redirect URI for OAuth callbacks
20
+ */
21
+ redirectUri: string;
22
+
23
+ /**
24
+ * Optional client name for metadata
25
+ */
26
+ clientName?: string;
27
+
28
+ /**
29
+ * Optional scopes to request
30
+ */
31
+ scopes?: string[];
32
+ }
33
+
34
+ /**
35
+ * OAuth provider for browser-based authorization code flow with PKCE.
36
+ *
37
+ * This provider handles:
38
+ * - Dynamic client registration
39
+ * - PKCE code verifier management
40
+ * - Token storage in localStorage/secure storage
41
+ * - Browser redirects for authorization
42
+ *
43
+ * @example
44
+ * const provider = new BrowserOAuthProvider({
45
+ * redirectUri: window.location.origin + "/#/oauth/callback",
46
+ * clientName: "MCP WebGPU Client",
47
+ * scopes: ["read", "write"]
48
+ * });
49
+ *
50
+ * const transport = new StreamableHTTPClientTransport(serverUrl, {
51
+ * authProvider: provider
52
+ * });
53
+ */
54
+ export class BrowserOAuthProvider implements OAuthClientProvider {
55
+ private _redirectUri: string;
56
+ private _clientName: string;
57
+ private _scopes: string[];
58
+
59
+ constructor(options: BrowserOAuthProviderOptions) {
60
+ this._redirectUri = options.redirectUri;
61
+ this._clientName = options.clientName || "MCP WebGPU Client";
62
+ this._scopes = options.scopes || [];
63
+ }
64
+
65
+ get redirectUrl(): string {
66
+ return this._redirectUri;
67
+ }
68
+
69
+ get clientMetadata(): OAuthClientMetadata {
70
+ return {
71
+ client_name: this._clientName,
72
+ redirect_uris: [this._redirectUri],
73
+ grant_types: ["authorization_code"],
74
+ response_types: ["code"],
75
+ token_endpoint_auth_method: "none", // Public client (browser-based)
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Load saved client information from localStorage
81
+ */
82
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
83
+ const clientId = localStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_ID);
84
+ if (!clientId) {
85
+ return undefined;
86
+ }
87
+
88
+ const clientSecret = await secureStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET);
89
+
90
+ return {
91
+ client_id: clientId,
92
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Save client information after dynamic registration
98
+ */
99
+ async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
100
+ localStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_ID, info.client_id);
101
+
102
+ if ("client_secret" in info && info.client_secret) {
103
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET, info.client_secret);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Load saved OAuth tokens
109
+ */
110
+ async tokens(): Promise<OAuthTokens | undefined> {
111
+ const accessToken = await secureStorage.getItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN);
112
+ if (!accessToken) {
113
+ return undefined;
114
+ }
115
+
116
+ const refreshToken = await secureStorage.getItem(STORAGE_KEYS.OAUTH_REFRESH_TOKEN);
117
+
118
+ return {
119
+ access_token: accessToken,
120
+ token_type: "Bearer",
121
+ ...(refreshToken ? { refresh_token: refreshToken } : {}),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Save OAuth tokens after successful authorization
127
+ */
128
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
129
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
130
+
131
+ if (tokens.refresh_token) {
132
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_REFRESH_TOKEN, tokens.refresh_token);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Redirect browser to authorization URL
138
+ */
139
+ redirectToAuthorization(authorizationUrl: URL): void {
140
+ window.location.href = authorizationUrl.toString();
141
+ }
142
+
143
+ /**
144
+ * Save PKCE code verifier before redirect
145
+ */
146
+ saveCodeVerifier(codeVerifier: string): void {
147
+ localStorage.setItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER, codeVerifier);
148
+ }
149
+
150
+ /**
151
+ * Load PKCE code verifier for token exchange
152
+ */
153
+ codeVerifier(): string {
154
+ const verifier = localStorage.getItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER);
155
+ if (!verifier) {
156
+ throw new Error("No code verifier found in storage");
157
+ }
158
+ return verifier;
159
+ }
160
+
161
+ /**
162
+ * Generate OAuth state parameter for CSRF protection
163
+ */
164
+ state(): string {
165
+ const state = crypto.randomUUID();
166
+ localStorage.setItem(STORAGE_KEYS.OAUTH_STATE, state);
167
+ return state;
168
+ }
169
+
170
+ /**
171
+ * Invalidate stored credentials
172
+ */
173
+ async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
174
+ switch (scope) {
175
+ case 'all':
176
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_CLIENT_ID);
177
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET);
178
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN);
179
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_REFRESH_TOKEN);
180
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER);
181
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_STATE);
182
+ break;
183
+ case 'client':
184
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_CLIENT_ID);
185
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET);
186
+ break;
187
+ case 'tokens':
188
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN);
189
+ await secureStorage.removeItem(STORAGE_KEYS.OAUTH_REFRESH_TOKEN);
190
+ break;
191
+ case 'verifier':
192
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER);
193
+ break;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Prepare token request parameters for authorization code exchange
199
+ */
200
+ async prepareTokenRequest(): Promise<URLSearchParams> {
201
+ const params = new URLSearchParams({
202
+ grant_type: "authorization_code",
203
+ redirect_uri: this._redirectUri,
204
+ });
205
+
206
+ if (this._scopes.length > 0) {
207
+ params.set("scope", this._scopes.join(" "));
208
+ }
209
+
210
+ return params;
211
+ }
212
+ }
src/services/mcpClient.ts CHANGED
@@ -9,7 +9,8 @@ import type {
9
  MCPClientState,
10
  MCPToolResult,
11
  } from "../types/mcp.js";
12
- import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants";
 
13
 
14
  export class MCPClientService {
15
  private clients: Map<string, Client> = new Map();
@@ -165,61 +166,72 @@ export class MCPClientService {
165
  let transport;
166
  const url = new URL(connection.config.url);
167
 
168
- // Prepare headers for authentication
169
- const headers: Record<string, string> = {};
170
- if (connection.config.auth) {
171
- switch (connection.config.auth.type) {
172
- case "bearer":
173
- if (connection.config.auth.token) {
174
- headers[
175
- "Authorization"
176
- ] = `Bearer ${connection.config.auth.token}`;
177
- }
178
- break;
179
- case "basic":
180
- if (
181
- connection.config.auth.username &&
182
- connection.config.auth.password
183
- ) {
184
- const credentials = btoa(
185
- `${connection.config.auth.username}:${connection.config.auth.password}`
186
- );
187
- headers["Authorization"] = `Basic ${credentials}`;
188
- }
189
- break;
190
- case "oauth":
191
- if (connection.config.auth.token) {
192
- headers[
193
- "Authorization"
194
- ] = `Bearer ${connection.config.auth.token}`;
195
- }
196
- break;
197
- }
198
- }
199
 
200
- switch (connection.config.transport) {
201
- case "streamable-http":
202
  transport = new StreamableHTTPClientTransport(url, {
203
- requestInit:
204
- Object.keys(headers).length > 0 ? { headers } : undefined,
205
  });
206
- break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
- case "sse":
209
- transport = new SSEClientTransport(url, {
210
- requestInit:
211
- Object.keys(headers).length > 0 ? { headers } : undefined,
212
- });
213
- break;
 
214
 
215
- default:
216
- throw new Error(
217
- `Unsupported transport: ${connection.config.transport}`
218
- );
 
 
 
 
 
 
 
 
219
  }
220
 
221
- // Set up error handling
222
  client.onerror = (error) => {
 
 
 
 
 
 
 
223
  connection.lastError = error.message;
224
  connection.isConnected = false;
225
  this.notifyStateChange();
 
9
  MCPClientState,
10
  MCPToolResult,
11
  } from "../types/mcp.js";
12
+ import { MCP_CLIENT_CONFIG, STORAGE_KEYS, DEFAULTS } from "../config/constants";
13
+ import { BrowserOAuthProvider } from "./BrowserOAuthProvider";
14
 
15
  export class MCPClientService {
16
  private clients: Map<string, Client> = new Map();
 
166
  let transport;
167
  const url = new URL(connection.config.url);
168
 
169
+ if (connection.config.auth?.type === "oauth") {
170
+ const oauthProvider = new BrowserOAuthProvider({
171
+ redirectUri: window.location.origin + "/#" + DEFAULTS.OAUTH_REDIRECT_PATH,
172
+ clientName: MCP_CLIENT_CONFIG.NAME,
173
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
+ if (connection.config.transport === "streamable-http") {
 
176
  transport = new StreamableHTTPClientTransport(url, {
177
+ authProvider: oauthProvider,
 
178
  });
179
+ } else {
180
+ throw new Error("OAuth authentication is only supported with streamable-http transport");
181
+ }
182
+ } else {
183
+ const headers: Record<string, string> = {};
184
+ if (connection.config.auth) {
185
+ switch (connection.config.auth.type) {
186
+ case "bearer":
187
+ if (connection.config.auth.token) {
188
+ headers["Authorization"] = `Bearer ${connection.config.auth.token}`;
189
+ }
190
+ break;
191
+ case "basic":
192
+ if (
193
+ connection.config.auth.username &&
194
+ connection.config.auth.password
195
+ ) {
196
+ const credentials = btoa(
197
+ `${connection.config.auth.username}:${connection.config.auth.password}`
198
+ );
199
+ headers["Authorization"] = `Basic ${credentials}`;
200
+ }
201
+ break;
202
+ }
203
+ }
204
 
205
+ switch (connection.config.transport) {
206
+ case "streamable-http":
207
+ transport = new StreamableHTTPClientTransport(url, {
208
+ requestInit:
209
+ Object.keys(headers).length > 0 ? { headers } : undefined,
210
+ });
211
+ break;
212
 
213
+ case "sse":
214
+ transport = new SSEClientTransport(url, {
215
+ requestInit:
216
+ Object.keys(headers).length > 0 ? { headers } : undefined,
217
+ });
218
+ break;
219
+
220
+ default:
221
+ throw new Error(
222
+ `Unsupported transport: ${connection.config.transport}`
223
+ );
224
+ }
225
  }
226
 
 
227
  client.onerror = (error) => {
228
+ if (error.message?.includes("Failed to send cancellation") &&
229
+ error.message?.includes("AbortError")) {
230
+ console.debug(`Ignoring benign cancellation error for ${serverId}:`, error.message);
231
+ return;
232
+ }
233
+
234
+ console.error(`MCP Client error for ${serverId}:`, error);
235
  connection.lastError = error.message;
236
  connection.isConnected = false;
237
  this.notifyStateChange();