From 2b53a8399c0d31e45682f1334791b2116ce7bd7c Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 23 Jan 2025 16:29:43 +0000 Subject: [PATCH 01/23] Bump SDK --- client/package.json | 2 +- package-lock.json | 54 +++++++++++++++++++++++++++++++++++++-------- server/package.json | 2 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/client/package.json b/client/package.json index 888495c..f3c62e3 100644 --- a/client/package.json +++ b/client/package.json @@ -21,7 +21,7 @@ "preview": "vite preview" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", diff --git a/package-lock.json b/package-lock.json index c5e4d88..cb63e79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", @@ -1205,13 +1205,31 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.3.tgz", - "integrity": "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.0.tgz", + "integrity": "sha512-50YTsT4H9PuqmgwXkr/BPl0ankfEfxqVtFG8I378XqUThasnYRdNwE2PqktiJNm5MncOf3s5q37juv6kMe6WZQ==", + "license": "MIT", "dependencies": { "content-type": "^1.0.5", + "eventsource": "^3.0.2", "raw-body": "^3.0.0", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", + "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -3697,6 +3715,15 @@ "node": ">=12.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", @@ -6927,20 +6954,29 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "server": { "name": "@modelcontextprotocol/inspector-server", "version": "0.3.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.0", "cors": "^2.8.5", "eventsource": "^2.0.2", "express": "^4.21.0", diff --git a/server/package.json b/server/package.json index c79e7d2..ed2ad72 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.0", "cors": "^2.8.5", "eventsource": "^2.0.2", "express": "^4.21.0", From 60b8892dd39c997bb90fcdfd257a1668bc7ac3fc Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 23 Jan 2025 16:30:19 +0000 Subject: [PATCH 02/23] Pre-emptively bump npm package versions Before I forget! --- client/package.json | 2 +- package.json | 2 +- server/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/package.json b/client/package.json index f3c62e3..f29f303 100644 --- a/client/package.json +++ b/client/package.json @@ -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)", diff --git a/package.json b/package.json index c262588..cf06f70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/server/package.json b/server/package.json index ed2ad72..8f05be1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.3.0", + "version": "0.4.0", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From c1e06c4af06a25acc59ce122f521f0638695c27c Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 23 Jan 2025 16:45:12 +0000 Subject: [PATCH 03/23] Server doesn't need to inject `eventsource` anymore --- package-lock.json | 26 ++++---------------------- server/package.json | 2 -- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb63e79..43f00a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "workspaces": [ "client", @@ -31,7 +31,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.0", @@ -2275,13 +2275,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eventsource": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", - "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -3706,15 +3699,6 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/eventsource-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", @@ -6973,12 +6957,11 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.0", "cors": "^2.8.5", - "eventsource": "^2.0.2", "express": "^4.21.0", "ws": "^8.18.0", "zod": "^3.23.8" @@ -6988,7 +6971,6 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/eventsource": "^1.1.15", "@types/express": "^4.17.21", "@types/ws": "^8.5.12", "tsx": "^4.19.0", diff --git a/server/package.json b/server/package.json index 8f05be1..155093c 100644 --- a/server/package.json +++ b/server/package.json @@ -20,7 +20,6 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/eventsource": "^1.1.15", "@types/express": "^4.17.21", "@types/ws": "^8.5.12", "tsx": "^4.19.0", @@ -29,7 +28,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.4.0", "cors": "^2.8.5", - "eventsource": "^2.0.2", "express": "^4.21.0", "ws": "^8.18.0", "zod": "^3.23.8" From e7697eb5cd8cd4df3ebb7966079d7782fb50cac0 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 23 Jan 2025 16:45:37 +0000 Subject: [PATCH 04/23] Pass through `Authorization` headers sent to inspector server --- server/src/index.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 9662009..0dbd85b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import cors from "cors"; -import EventSource from "eventsource"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; @@ -12,18 +11,16 @@ import { } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; -import mcpProxy from "./mcpProxy.js"; import { findActualExecutable } from "spawn-rx"; +import mcpProxy from "./mcpProxy.js"; + +const SSE_HEADERS_PASSTHROUGH = ['Authorization']; const defaultEnvironment = { ...getDefaultEnvironment(), ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}), }; -// Polyfill EventSource for an SSE client in Node.js -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(global as any).EventSource = EventSource; - const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -37,7 +34,8 @@ app.use(cors()); let webAppTransports: SSEServerTransport[] = []; -const createTransport = async (query: express.Request["query"]) => { +const createTransport = async (req: express.Request) => { + const query = req.query; console.log("Query parameters:", query); const transportType = query.transportType as string; @@ -65,9 +63,27 @@ const createTransport = async (query: express.Request["query"]) => { return transport; } else if (transportType === "sse") { const url = query.url as string; - console.log(`SSE transport: url=${url}`); + const headers: HeadersInit = {}; + for (const key of SSE_HEADERS_PASSTHROUGH) { + if (req.headers[key] === undefined) { + continue; - const transport = new SSEClientTransport(new URL(url)); + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); + + const transport = new SSEClientTransport(new URL(url), { + eventSourceInit: { + fetch: (url, init) => fetch(url, { ...init, headers }), + }, + requestInit: { + headers, + }, + }); await transport.start(); console.log("Connected to SSE transport"); @@ -82,7 +98,7 @@ app.get("/sse", async (req, res) => { try { console.log("New SSE connection"); - const backingServerTransport = await createTransport(req.query); + const backingServerTransport = await createTransport(req); console.log("Connected MCP client to backing server transport"); @@ -152,4 +168,4 @@ app.get("/config", (req, res) => { }); const PORT = process.env.PORT || 3000; -app.listen(PORT, () => {}); +app.listen(PORT, () => { }); From 14db05c2a207d0357601676cf6dad5d87d3cf571 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 23 Jan 2025 17:19:39 +0000 Subject: [PATCH 05/23] Clarify inspector-server error logging --- server/src/index.ts | 3 --- server/src/mcpProxy.ts | 23 ++++++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 0dbd85b..538509e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -125,9 +125,6 @@ app.get("/sse", async (req, res) => { mcpProxy({ transportToClient: webAppTransport, transportToServer: backingServerTransport, - onerror: (error) => { - console.error(error); - }, }); console.log("Set up MCP proxy"); diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 7932845..b93c0cd 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -1,23 +1,29 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +function onClientError(error: Error) { + console.error("Error from inspector client:", error); +} + +function onServerError(error: Error) { + console.error("Error from MCP server:", error); +} + export default function mcpProxy({ transportToClient, transportToServer, - onerror, }: { transportToClient: Transport; transportToServer: Transport; - onerror: (error: Error) => void; }) { let transportToClientClosed = false; let transportToServerClosed = false; transportToClient.onmessage = (message) => { - transportToServer.send(message).catch(onerror); + transportToServer.send(message).catch(onServerError); }; transportToServer.onmessage = (message) => { - transportToClient.send(message).catch(onerror); + transportToClient.send(message).catch(onClientError); }; transportToClient.onclose = () => { @@ -26,7 +32,7 @@ export default function mcpProxy({ } transportToClientClosed = true; - transportToServer.close().catch(onerror); + transportToServer.close().catch(onServerError); }; transportToServer.onclose = () => { @@ -34,10 +40,9 @@ export default function mcpProxy({ return; } transportToServerClosed = true; - - transportToClient.close().catch(onerror); + transportToClient.close().catch(onClientError); }; - transportToClient.onerror = onerror; - transportToServer.onerror = onerror; + transportToClient.onerror = onClientError; + transportToServer.onerror = onServerError; } From 8bb53087975f3c18c302e19c6dc3ee30c55f2911 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 11:04:44 +0000 Subject: [PATCH 06/23] Report SSE 401 errors to the client --- server/src/errors.ts | 11 +++++++++++ server/src/index.ts | 14 +++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 server/src/errors.ts diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 0000000..eba1d40 --- /dev/null +++ b/server/src/errors.ts @@ -0,0 +1,11 @@ +export interface SseError extends Error { + code: number; +} + +export function isSseError(error: unknown): error is SseError { + if (!(error instanceof Error)) { + return false; + } + + return "code" in error && typeof error.code === "number"; +} diff --git a/server/src/index.ts b/server/src/index.ts index 538509e..1eaa7ae 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,7 @@ import { import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; import { findActualExecutable } from "spawn-rx"; +import { isSseError } from "./errors.js"; import mcpProxy from "./mcpProxy.js"; const SSE_HEADERS_PASSTHROUGH = ['Authorization']; @@ -98,7 +99,18 @@ app.get("/sse", async (req, res) => { try { console.log("New SSE connection"); - const backingServerTransport = await createTransport(req); + let backingServerTransport; + try { + backingServerTransport = await createTransport(req); + } catch (error) { + if (isSseError(error) && error.code === 401) { + console.error("Received 401 Unauthorized from MCP server:", error.message); + res.status(401).json(error); + return; + } + + throw error; + } console.log("Connected MCP client to backing server transport"); From 8a20f7711ac70639d88f48a785f0fdc59a38a9f7 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 11:27:40 +0000 Subject: [PATCH 07/23] Use new `SseError` class from SDK --- server/src/errors.ts | 11 ----------- server/src/index.ts | 5 ++--- 2 files changed, 2 insertions(+), 14 deletions(-) delete mode 100644 server/src/errors.ts diff --git a/server/src/errors.ts b/server/src/errors.ts deleted file mode 100644 index eba1d40..0000000 --- a/server/src/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface SseError extends Error { - code: number; -} - -export function isSseError(error: unknown): error is SseError { - if (!(error instanceof Error)) { - return false; - } - - return "code" in error && typeof error.code === "number"; -} diff --git a/server/src/index.ts b/server/src/index.ts index 1eaa7ae..1428e80 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,7 +4,7 @@ import cors from "cors"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport, getDefaultEnvironment, @@ -12,7 +12,6 @@ import { import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; import { findActualExecutable } from "spawn-rx"; -import { isSseError } from "./errors.js"; import mcpProxy from "./mcpProxy.js"; const SSE_HEADERS_PASSTHROUGH = ['Authorization']; @@ -103,7 +102,7 @@ app.get("/sse", async (req, res) => { try { backingServerTransport = await createTransport(req); } catch (error) { - if (isSseError(error) && error.code === 401) { + if (error instanceof SseError && error.code === 401) { console.error("Received 401 Unauthorized from MCP server:", error.message); res.status(401).json(error); return; From 1c4ad60354592a886566383bb5cee3cd95cdd1a9 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 11:34:07 +0000 Subject: [PATCH 08/23] Redirect into OAuth flow upon receiving 401 --- client/package.json | 1 + client/src/lib/auth.ts | 52 +++++++++++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 17 +++++++-- package-lock.json | 10 ++++++ 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 client/src/lib/auth.ts diff --git a/client/package.json b/client/package.json index f29f303..eb96502 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts new file mode 100644 index 0000000..ce49d05 --- /dev/null +++ b/client/src/lib/auth.ts @@ -0,0 +1,52 @@ +import pkceChallenge from 'pkce-challenge'; + +export interface OAuthMetadata { + authorization_endpoint: string; + token_endpoint: string; +} + +export async function discoverOAuthMetadata(serverUrl: string): Promise { + 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 { + // 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('mcp_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(); +} diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index ef937d1..78d3a7d 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -1,5 +1,5 @@ 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, @@ -12,8 +12,9 @@ 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 { Notification, StdErrNotificationSchema } from "../notificationTypes"; const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; @@ -160,7 +161,17 @@ 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) { + const redirectUrl = await startOAuthFlow(sseUrl); + window.location.href = redirectUrl; + } + + throw error; + } const capabilities = client.getServerCapabilities(); setServerCapabilities(capabilities ?? null); diff --git a/package-lock.json b/package-lock.json index 43f00a5..a29a1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,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", @@ -4946,6 +4947,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", From 16cb59670c8918976a5e6c08f86425ceb91b904e Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 11:37:35 +0000 Subject: [PATCH 09/23] OAuth callback handler (not yet attached) --- client/src/lib/auth.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index ce49d05..054f88e 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -50,3 +50,35 @@ export async function startOAuthFlow(serverUrl: string): Promise { return authUrl.toString(); } + +export async function handleOAuthCallback(serverUrl: string, code: string): Promise { + // Get stored code verifier + const codeVerifier = sessionStorage.getItem('mcp_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/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + 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; +} \ No newline at end of file From 23f89e49b810842b82d7fdc19b650c89a4bf43ac Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:08:39 +0000 Subject: [PATCH 10/23] Implement OAuth callback --- client/src/App.tsx | 11 ++++++- client/src/components/OAuthCallback.tsx | 39 +++++++++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 2 ++ client/vite.config.ts | 3 ++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 client/src/components/OAuthCallback.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index f3791b2..9fad257 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,7 +18,7 @@ import { ServerNotification, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, Suspense } from "react"; import { StdErrNotification } from "./lib/notificationTypes"; @@ -49,6 +49,15 @@ 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 ( + Loading...}> + + + ); + } const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx new file mode 100644 index 0000000..67fc91f --- /dev/null +++ b/client/src/components/OAuthCallback.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { handleOAuthCallback } from '../lib/auth'; + +const OAuthCallback = () => { + useEffect(() => { + const handleCallback = async () => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const serverUrl = sessionStorage.getItem('mcp_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('mcp_access_token', accessToken); + // Redirect back to the main app + window.location.href = '/'; + } catch (error) { + console.error('OAuth callback error:', error); + window.location.href = '/'; + } + }; + + void handleCallback(); + }, []); + + return ( +
+

Processing OAuth callback...

+
+ ); +}; + +export default OAuthCallback; \ No newline at end of file diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 78d3a7d..f12d3cd 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -166,6 +166,8 @@ export function useConnection({ } 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('mcp_server_url', sseUrl); const redirectUrl = await startOAuthFlow(sseUrl); window.location.href = redirectUrl; } diff --git a/client/vite.config.ts b/client/vite.config.ts index dd3bd01..8ef6cbc 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -5,6 +5,9 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: { + historyApiFallback: true, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), From 02cfb47c83469ff2f855fc8071508784c3058992 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:09:58 +0000 Subject: [PATCH 11/23] Extract session storage keys into constants --- client/src/components/OAuthCallback.tsx | 5 +++-- client/src/lib/auth.ts | 5 +++-- client/src/lib/constants.ts | 6 ++++++ client/src/lib/hooks/useConnection.ts | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 client/src/lib/constants.ts diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 67fc91f..bc3e1dd 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -1,12 +1,13 @@ import { useEffect } from 'react'; import { handleOAuthCallback } from '../lib/auth'; +import { SESSION_KEYS } from '../lib/constants'; const OAuthCallback = () => { useEffect(() => { const handleCallback = async () => { const params = new URLSearchParams(window.location.search); const code = params.get('code'); - const serverUrl = sessionStorage.getItem('mcp_server_url'); + const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); if (!code || !serverUrl) { console.error('Missing code or server URL'); @@ -17,7 +18,7 @@ const OAuthCallback = () => { try { const accessToken = await handleOAuthCallback(serverUrl, code); // Store the access token for future use - sessionStorage.setItem('mcp_access_token', accessToken); + sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken); // Redirect back to the main app window.location.href = '/'; } catch (error) { diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 054f88e..c8faa57 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -1,4 +1,5 @@ import pkceChallenge from 'pkce-challenge'; +import { SESSION_KEYS } from './constants'; export interface OAuthMetadata { authorization_endpoint: string; @@ -36,7 +37,7 @@ export async function startOAuthFlow(serverUrl: string): Promise { const codeChallenge = challenge.code_challenge; // Store code verifier for later use - sessionStorage.setItem('mcp_code_verifier', codeVerifier); + sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier); // Discover OAuth endpoints const metadata = await discoverOAuthMetadata(serverUrl); @@ -53,7 +54,7 @@ export async function startOAuthFlow(serverUrl: string): Promise { export async function handleOAuthCallback(serverUrl: string, code: string): Promise { // Get stored code verifier - const codeVerifier = sessionStorage.getItem('mcp_code_verifier'); + const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER); if (!codeVerifier) { throw new Error('No code verifier found'); } diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts new file mode 100644 index 0000000..b882179 --- /dev/null +++ b/client/src/lib/constants.ts @@ -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; \ No newline at end of file diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f12d3cd..96a50a3 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -14,6 +14,7 @@ import { useState } from "react"; import { toast } from "react-toastify"; import { z } from "zod"; import { startOAuthFlow } from "../auth"; +import { SESSION_KEYS } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; @@ -167,7 +168,7 @@ export function useConnection({ 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('mcp_server_url', sseUrl); + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); const redirectUrl = await startOAuthFlow(sseUrl); window.location.href = redirectUrl; } From e470eb5c5167b2aa18ca14e85ba1586008a98015 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:27:20 +0000 Subject: [PATCH 12/23] Fix React import --- client/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9fad257..1c6d6b6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { useConnection } from "./lib/hooks/useConnection"; import { From 874320ebe6da244867a38487b53061902bc93ece Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:44:26 +0000 Subject: [PATCH 13/23] Token exchange body needs to be JSON --- client/src/lib/auth.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index c8faa57..d4eddb7 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -10,7 +10,7 @@ export async function discoverOAuthMetadata(serverUrl: string): Promise { 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(); } @@ -58,28 +58,28 @@ export async function handleOAuthCallback(serverUrl: string, code: string): Prom 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/x-www-form-urlencoded', + 'Content-Type': 'application/json', }, - body: new URLSearchParams({ + 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; -} \ No newline at end of file +} From af8877064ea201d00bc21521c50c7e6bb7d80a61 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:55:32 +0000 Subject: [PATCH 14/23] Set Authorization header from client --- client/src/lib/hooks/useConnection.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 96a50a3..2852690 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -146,7 +146,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( From 731ee588c2a2a7b57d0f06fdc1f38785f22367e9 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 13:55:43 +0000 Subject: [PATCH 15/23] Fix Authorization header passthrough Node.js headers are lowercase --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 1428e80..c215f34 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,7 +14,7 @@ import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; -const SSE_HEADERS_PASSTHROUGH = ['Authorization']; +const SSE_HEADERS_PASSTHROUGH = ['authorization']; const defaultEnvironment = { ...getDefaultEnvironment(), From a6d22cf1e4f28dceebed250d008cefe167b0ec4b Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 14:54:46 +0000 Subject: [PATCH 16/23] Bump SDK version --- client/package.json | 2 +- package-lock.json | 10 +++++----- server/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/package.json b/client/package.json index eb96502..69021c8 100644 --- a/client/package.json +++ b/client/package.json @@ -21,7 +21,7 @@ "preview": "vite preview" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.4.0", + "@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", diff --git a/package-lock.json b/package-lock.json index a29a1ca..0260f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "version": "0.4.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.4.0", + "@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", @@ -1206,9 +1206,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.0.tgz", - "integrity": "sha512-50YTsT4H9PuqmgwXkr/BPl0ankfEfxqVtFG8I378XqUThasnYRdNwE2PqktiJNm5MncOf3s5q37juv6kMe6WZQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.1.tgz", + "integrity": "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -6970,7 +6970,7 @@ "version": "0.4.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.4.0", + "@modelcontextprotocol/sdk": "^1.4.1", "cors": "^2.8.5", "express": "^4.21.0", "ws": "^8.18.0", diff --git a/server/package.json b/server/package.json index 155093c..eb711f5 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.4.0", + "@modelcontextprotocol/sdk": "^1.4.1", "cors": "^2.8.5", "express": "^4.21.0", "ws": "^8.18.0", From 3bc776f7cd66ba13dfe9a77ca222078eebfb8573 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 14:55:10 +0000 Subject: [PATCH 17/23] Fix Vite config --- client/vite.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/vite.config.ts b/client/vite.config.ts index 8ef6cbc..bda10e5 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,12 +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: { - historyApiFallback: true, }, resolve: { alias: { From 99d7592ac932dc034f533c26f757e680db7c9ceb Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:02:34 +0000 Subject: [PATCH 18/23] Fix error state being briefly shown before OAuth --- client/src/lib/hooks/useConnection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 2852690..dda734a 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -184,6 +184,7 @@ export function useConnection({ sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); const redirectUrl = await startOAuthFlow(sseUrl); window.location.href = redirectUrl; + return; } throw error; From c22f91858cd99b18bd7b359d55e2871440917c3d Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:04:22 +0000 Subject: [PATCH 19/23] Remember last selected transport and SSE URL --- client/src/App.tsx | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1c6d6b6..39c13c2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { useConnection } from "./lib/hooks/useConnection"; import { @@ -51,8 +51,10 @@ 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')); + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); return ( Loading...}> @@ -81,8 +83,14 @@ const App = () => { return localStorage.getItem("lastArgs") || ""; }); - const [sseUrl, setSseUrl] = useState("http://localhost:3001/sse"); - const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); + const [sseUrl, setSseUrl] = useState(() => { + 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([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -200,6 +208,14 @@ const App = () => { localStorage.setItem("lastArgs", args); }, [args]); + useEffect(() => { + localStorage.setItem("lastSseUrl", sseUrl); + }, [sseUrl]); + + useEffect(() => { + localStorage.setItem("lastTransportType", transportType); + }, [transportType]); + useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) From 0648ba44e30c9ce7346157f633479a57d403f4e9 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:17:03 +0000 Subject: [PATCH 20/23] Auto-reconnect after OAuth --- client/src/App.tsx | 15 +++++++++++++++ client/src/components/OAuthCallback.tsx | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 39c13c2..e1d5d46 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -216,6 +216,21 @@ const App = () => { 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()); + // Connect to the server + connectMcpServer(); + } + }, []); + useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index bc3e1dd..3887be1 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -19,8 +19,8 @@ const OAuthCallback = () => { 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 - window.location.href = '/'; + // 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 = '/'; From 51ea4bc6ac1d1b6a0a074c076241fccd3ddada83 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:19:41 +0000 Subject: [PATCH 21/23] Add toast when OAuth succeeds --- client/src/App.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index e1d5d46..8deba66 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,3 @@ -import React from "react"; -import { useDraggablePane } from "./lib/hooks/useDraggablePane"; -import { useConnection } from "./lib/hooks/useConnection"; import { ClientRequest, CompatibilityCallToolResult, @@ -11,15 +8,17 @@ import { ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, - ReadResourceResultSchema, ListToolsResultSchema, + ReadResourceResultSchema, Resource, ResourceTemplate, Root, ServerNotification, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { useEffect, useRef, useState, Suspense } 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"; @@ -33,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"; @@ -226,6 +226,8 @@ const App = () => { 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(); } @@ -444,8 +446,8 @@ const App = () => {
{!serverCapabilities?.resources && - !serverCapabilities?.prompts && - !serverCapabilities?.tools ? ( + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? (

The connected server does not support any MCP capabilities From fce6644e30e25cc9bc85d17048fa33ad5c4d7c93 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:22:40 +0000 Subject: [PATCH 22/23] Fix double fetching --- client/src/components/OAuthCallback.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 3887be1..4729e71 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -1,10 +1,18 @@ -import { useEffect } from 'react'; +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); From 0882a3e0e59d20ce98fb6af6a234bcdb951453c5 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 24 Jan 2025 15:23:24 +0000 Subject: [PATCH 23/23] Formatting --- client/src/App.tsx | 6 ++-- client/src/components/OAuthCallback.tsx | 18 +++++----- client/src/lib/auth.ts | 48 ++++++++++++++----------- client/src/lib/constants.ts | 8 ++--- client/src/lib/hooks/useConnection.ts | 7 ++-- client/vite.config.ts | 3 +- server/src/index.ts | 15 +++++--- 7 files changed, 60 insertions(+), 45 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8deba66..246e035 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -227,7 +227,7 @@ const App = () => { newUrl.searchParams.delete("serverUrl"); window.history.replaceState({}, "", newUrl.toString()); // Show success toast for OAuth - toast.success('Successfully authenticated with OAuth'); + toast.success("Successfully authenticated with OAuth"); // Connect to the server connectMcpServer(); } @@ -446,8 +446,8 @@ const App = () => {

{!serverCapabilities?.resources && - !serverCapabilities?.prompts && - !serverCapabilities?.tools ? ( + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? (

The connected server does not support any MCP capabilities diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 4729e71..a7439df 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef } from 'react'; -import { handleOAuthCallback } from '../lib/auth'; -import { SESSION_KEYS } from '../lib/constants'; +import { useEffect, useRef } from "react"; +import { handleOAuthCallback } from "../lib/auth"; +import { SESSION_KEYS } from "../lib/constants"; const OAuthCallback = () => { const hasProcessedRef = useRef(false); @@ -14,12 +14,12 @@ const OAuthCallback = () => { hasProcessedRef.current = true; const params = new URLSearchParams(window.location.search); - const code = params.get('code'); + 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 = '/'; + console.error("Missing code or server URL"); + window.location.href = "/"; return; } @@ -30,8 +30,8 @@ const OAuthCallback = () => { // 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 = '/'; + console.error("OAuth callback error:", error); + window.location.href = "/"; } }; @@ -45,4 +45,4 @@ const OAuthCallback = () => { ); }; -export default OAuthCallback; \ No newline at end of file +export default OAuthCallback; diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index d4eddb7..0417731 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -1,32 +1,34 @@ -import pkceChallenge from 'pkce-challenge'; -import { SESSION_KEYS } from './constants'; +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 { +export async function discoverOAuthMetadata( + serverUrl: string, +): Promise { try { - const url = new URL('/.well-known/oauth-authorization-server', serverUrl); + 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 + token_endpoint: metadata.token_endpoint, }; } } catch (error) { - console.warn('OAuth metadata discovery failed:', 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() + authorization_endpoint: new URL("/authorize", baseUrl).toString(), + token_endpoint: new URL("/token", baseUrl).toString(), }; } @@ -44,19 +46,25 @@ export async function startOAuthFlow(serverUrl: string): Promise { // 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'); + 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 { +export async function handleOAuthCallback( + serverUrl: string, + code: string, +): Promise { // Get stored code verifier const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER); if (!codeVerifier) { - throw new Error('No code verifier found'); + throw new Error("No code verifier found"); } // Discover OAuth endpoints @@ -64,20 +72,20 @@ export async function handleOAuthCallback(serverUrl: string, code: string): Prom // Exchange code for tokens const response = await fetch(metadata.token_endpoint, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ - grant_type: 'authorization_code', + grant_type: "authorization_code", code, code_verifier: codeVerifier, - redirect_uri: window.location.origin + '/oauth/callback' - }) + redirect_uri: window.location.origin + "/oauth/callback", + }), }); if (!response.ok) { - throw new Error('Token exchange failed'); + throw new Error("Token exchange failed"); } const data = await response.json(); diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index b882179..e302b52 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -1,6 +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; \ No newline at end of file + CODE_VERIFIER: "mcp_code_verifier", + SERVER_URL: "mcp_server_url", + ACCESS_TOKEN: "mcp_access_token", +} as const; diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index dda734a..de2d29e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -1,5 +1,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + SSEClientTransport, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; import { ClientNotification, ClientRequest, @@ -149,7 +152,7 @@ export function useConnection({ const headers: HeadersInit = {}; const accessToken = sessionStorage.getItem(SESSION_KEYS.ACCESS_TOKEN); if (accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; + headers["Authorization"] = `Bearer ${accessToken}`; } const clientTransport = new SSEClientTransport(backendUrl, { diff --git a/client/vite.config.ts b/client/vite.config.ts index bda10e5..b3d0f45 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -5,8 +5,7 @@ import { defineConfig } from "vite"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - server: { - }, + server: {}, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/server/src/index.ts b/server/src/index.ts index c215f34..4d8ac42 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,7 +4,10 @@ import cors from "cors"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; -import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + SSEClientTransport, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport, getDefaultEnvironment, @@ -14,7 +17,7 @@ import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; -const SSE_HEADERS_PASSTHROUGH = ['authorization']; +const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const defaultEnvironment = { ...getDefaultEnvironment(), @@ -67,7 +70,6 @@ const createTransport = async (req: express.Request) => { for (const key of SSE_HEADERS_PASSTHROUGH) { if (req.headers[key] === undefined) { continue; - } const value = req.headers[key]; @@ -103,7 +105,10 @@ app.get("/sse", async (req, res) => { backingServerTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { - console.error("Received 401 Unauthorized from MCP server:", error.message); + console.error( + "Received 401 Unauthorized from MCP server:", + error.message, + ); res.status(401).json(error); return; } @@ -176,4 +181,4 @@ app.get("/config", (req, res) => { }); const PORT = process.env.PORT || 3000; -app.listen(PORT, () => { }); +app.listen(PORT, () => {});