Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa723abbe0 | ||
|
|
410a6f33dc | ||
|
|
b324378b2c | ||
|
|
6d930ecae7 | ||
|
|
9c3fee1442 | ||
|
|
688752ea77 | ||
|
|
1b13b574f8 | ||
|
|
95bbd60a38 | ||
|
|
96ba6fd531 | ||
|
|
8592cf2d07 | ||
|
|
dd47b574b3 | ||
|
|
b4ae1327b5 | ||
|
|
b5762d53fd | ||
|
|
7957d9f577 | ||
|
|
4c89aed4d9 | ||
|
|
79547143a8 | ||
|
|
d438760e36 | ||
|
|
d0ad677784 | ||
|
|
1d4e8885db | ||
|
|
a87bd17f51 | ||
|
|
afe14bc883 | ||
|
|
04faff4757 | ||
|
|
f980763381 | ||
|
|
d754395a9a | ||
|
|
df955cfdb5 | ||
|
|
5b884b55b5 | ||
|
|
0882a3e0e5 | ||
|
|
fce6644e30 | ||
|
|
51ea4bc6ac | ||
|
|
0648ba44e3 | ||
|
|
c22f91858c | ||
|
|
99d7592ac9 | ||
|
|
3bc776f7cd | ||
|
|
a6d22cf1e4 | ||
|
|
731ee588c2 | ||
|
|
af8877064e | ||
|
|
874320ebe6 | ||
|
|
e470eb5c51 | ||
|
|
02cfb47c83 | ||
|
|
23f89e49b8 | ||
|
|
16cb59670c | ||
|
|
1c4ad60354 | ||
|
|
8a20f7711a | ||
|
|
8bb5308797 | ||
|
|
14db05c2a2 | ||
|
|
e7697eb5cd | ||
|
|
c1e06c4af0 | ||
|
|
60b8892dd3 | ||
|
|
2b53a8399c | ||
|
|
361f9d109b | ||
|
|
7ec661e8bd | ||
|
|
98e6f0e5ec | ||
|
|
ec150eb8b4 | ||
|
|
052de8690d | ||
|
|
a976aefb39 | ||
|
|
5a5873277c | ||
|
|
715936d747 | ||
|
|
d973f58bef | ||
|
|
1797fbfba8 | ||
|
|
8f4013c42c | ||
|
|
1abb7ca59c | ||
|
|
dfb36e1792 | ||
|
|
ffc29663c8 | ||
|
|
53226dd391 | ||
|
|
dc49d46baa | ||
|
|
ef32a8f289 | ||
|
|
54e9957ec5 | ||
|
|
7edde5001b | ||
|
|
14bda1f030 | ||
|
|
1f4d35f8a3 | ||
|
|
eb70539958 | ||
|
|
7878e1764a | ||
|
|
26f0cb3c8b | ||
|
|
8f40e052c1 | ||
|
|
024f06c1b7 | ||
|
|
1ddc63b330 | ||
|
|
27bd503240 | ||
|
|
b39c96de7c | ||
|
|
d857e1462b | ||
|
|
2eae823d65 | ||
|
|
79ba164fda | ||
|
|
2f513df6c1 | ||
|
|
ce68085e77 | ||
|
|
e96b3be159 | ||
|
|
034699524a | ||
|
|
fdc521646f | ||
|
|
bd6586bbad | ||
|
|
c340e5f1ed | ||
|
|
cc1ae05f9d | ||
|
|
9ea77a729c | ||
|
|
8c7b0c360e | ||
|
|
35effc4d16 | ||
|
|
576ff0043a | ||
|
|
18dc4d0a99 | ||
|
|
ed5017d73e | ||
|
|
f04b161411 | ||
|
|
bd6a63603a | ||
|
|
b845444fab | ||
|
|
14802b8043 | ||
|
|
ace94c4d37 | ||
|
|
50640bc9cc | ||
|
|
068d21387a | ||
|
|
66b1b73448 | ||
|
|
cc17ba8d56 | ||
|
|
764f02310d |
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -14,6 +14,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx prettier --check .
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
packages
|
packages
|
||||||
server/build
|
server/build
|
||||||
|
CODE_OF_CONDUCT.md
|
||||||
|
SECURITY.md
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -14,10 +14,20 @@ To inspect an MCP server implementation, there's no need to clone this repo. Ins
|
|||||||
npx @modelcontextprotocol/inspector build/index.js
|
npx @modelcontextprotocol/inspector build/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also pass arguments along which will get passed as arguments to your MCP server:
|
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
# Pass arguments only
|
||||||
|
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
|
||||||
|
|
||||||
|
# Pass environment variables only
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js
|
||||||
|
|
||||||
|
# Pass both environment variables and arguments
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2
|
||||||
|
|
||||||
|
# Use -- to separate inspector flags from server arguments
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag
|
||||||
```
|
```
|
||||||
|
|
||||||
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
|
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
|
||||||
@@ -26,6 +36,8 @@ The inspector runs both a client UI (default port 5173) and an MCP proxy server
|
|||||||
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
|
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
|
||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
|
|
||||||
If you're working on the inspector itself:
|
If you're working on the inspector itself:
|
||||||
|
|||||||
34
bin/cli.js
34
bin/cli.js
@@ -11,8 +11,32 @@ function delay(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Get command line arguments
|
// Parse command line arguments
|
||||||
const [, , command, ...mcpServerArgs] = process.argv;
|
const args = process.argv.slice(2);
|
||||||
|
const envVars = {};
|
||||||
|
const mcpServerArgs = [];
|
||||||
|
let command = null;
|
||||||
|
let parsingFlags = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
|
||||||
|
if (parsingFlags && arg === "--") {
|
||||||
|
parsingFlags = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||||
|
const [key, value] = args[++i].split("=");
|
||||||
|
if (key && value) {
|
||||||
|
envVars[key] = value;
|
||||||
|
}
|
||||||
|
} else if (!command) {
|
||||||
|
command = arg;
|
||||||
|
} else {
|
||||||
|
mcpServerArgs.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inspectorServerPath = resolve(
|
const inspectorServerPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -52,7 +76,11 @@ async function main() {
|
|||||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: { ...process.env, PORT: SERVER_PORT },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: SERVER_PORT,
|
||||||
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
|
},
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.2.7",
|
"version": "0.4.1",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
|
"pkce-challenge": "^4.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-toastify": "^10.0.6",
|
"react-toastify": "^10.0.6",
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
||||||
import {
|
import {
|
||||||
ClientNotification,
|
|
||||||
ClientRequest,
|
ClientRequest,
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
CompatibilityCallToolResultSchema,
|
CompatibilityCallToolResultSchema,
|
||||||
CreateMessageRequestSchema,
|
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
EmptyResultSchema,
|
EmptyResultSchema,
|
||||||
GetPromptResultSchema,
|
GetPromptResultSchema,
|
||||||
ListPromptsResultSchema,
|
ListPromptsResultSchema,
|
||||||
ListResourcesResultSchema,
|
ListResourcesResultSchema,
|
||||||
ListResourceTemplatesResultSchema,
|
ListResourceTemplatesResultSchema,
|
||||||
ListRootsRequestSchema,
|
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
ReadResourceResultSchema,
|
ReadResourceResultSchema,
|
||||||
Request,
|
|
||||||
Resource,
|
Resource,
|
||||||
ResourceTemplate,
|
ResourceTemplate,
|
||||||
Result,
|
|
||||||
Root,
|
Root,
|
||||||
ServerNotification,
|
ServerNotification,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { useCallback, 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 {
|
import { StdErrNotification } from "./lib/notificationTypes";
|
||||||
Notification,
|
|
||||||
StdErrNotification,
|
|
||||||
StdErrNotificationSchema,
|
|
||||||
} from "./lib/notificationTypes";
|
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +33,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { ZodType } from "zod";
|
import { z } from "zod";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
import HistoryAndNotifications from "./components/History";
|
import HistoryAndNotifications from "./components/History";
|
||||||
@@ -55,16 +45,22 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
|||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import ToolsTab from "./components/ToolsTab";
|
import ToolsTab from "./components/ToolsTab";
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
||||||
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [connectionStatus, setConnectionStatus] = useState<
|
// Handle OAuth callback route
|
||||||
"disconnected" | "connected" | "error"
|
if (window.location.pathname === "/oauth/callback") {
|
||||||
>("disconnected");
|
const OAuthCallback = React.lazy(
|
||||||
|
() => import("./components/OAuthCallback"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<OAuthCallback />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
ResourceTemplate[]
|
ResourceTemplate[]
|
||||||
@@ -87,12 +83,14 @@ const App = () => {
|
|||||||
return localStorage.getItem("lastArgs") || "";
|
return localStorage.getItem("lastArgs") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
|
const [sseUrl, setSseUrl] = useState<string>(() => {
|
||||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
|
||||||
const [requestHistory, setRequestHistory] = useState<
|
});
|
||||||
{ request: string; response?: string }[]
|
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
|
||||||
>([]);
|
return (
|
||||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
||||||
|
);
|
||||||
|
});
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||||
StdErrNotification[]
|
StdErrNotification[]
|
||||||
@@ -143,49 +141,64 @@ const App = () => {
|
|||||||
>();
|
>();
|
||||||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||||
const progressTokenRef = useRef(0);
|
const progressTokenRef = useRef(0);
|
||||||
const [historyPaneHeight, setHistoryPaneHeight] = useState<number>(300);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const dragStartY = useRef<number>(0);
|
|
||||||
const dragStartHeight = useRef<number>(0);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
setIsDragging(true);
|
const {
|
||||||
dragStartY.current = e.clientY;
|
connectionStatus,
|
||||||
dragStartHeight.current = historyPaneHeight;
|
serverCapabilities,
|
||||||
document.body.style.userSelect = "none";
|
mcpClient,
|
||||||
|
requestHistory,
|
||||||
|
makeRequest: makeConnectionRequest,
|
||||||
|
sendNotification,
|
||||||
|
connect: connectMcpServer,
|
||||||
|
} = useConnection({
|
||||||
|
transportType,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
sseUrl,
|
||||||
|
env,
|
||||||
|
proxyServerUrl: PROXY_SERVER_URL,
|
||||||
|
onNotification: (notification) => {
|
||||||
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
[historyPaneHeight],
|
onStdErrNotification: (notification) => {
|
||||||
);
|
setStdErrNotifications((prev) => [
|
||||||
|
...prev,
|
||||||
const handleDragMove = useCallback(
|
notification as StdErrNotification,
|
||||||
(e: MouseEvent) => {
|
]);
|
||||||
if (!isDragging) return;
|
|
||||||
const deltaY = dragStartY.current - e.clientY;
|
|
||||||
const newHeight = Math.max(
|
|
||||||
100,
|
|
||||||
Math.min(800, dragStartHeight.current + deltaY),
|
|
||||||
);
|
|
||||||
setHistoryPaneHeight(newHeight);
|
|
||||||
},
|
},
|
||||||
[isDragging],
|
onPendingRequest: (request, resolve, reject) => {
|
||||||
);
|
setPendingSampleRequests((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: nextRequestId.current++, request, resolve, reject },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
getRoots: () => rootsRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
setIsDragging(false);
|
request: ClientRequest,
|
||||||
document.body.style.userSelect = "";
|
schema: T,
|
||||||
}, []);
|
tabKey?: keyof typeof errors,
|
||||||
|
) => {
|
||||||
useEffect(() => {
|
try {
|
||||||
if (isDragging) {
|
const response = await makeConnectionRequest(request, schema);
|
||||||
window.addEventListener("mousemove", handleDragMove);
|
if (tabKey !== undefined) {
|
||||||
window.addEventListener("mouseup", handleDragEnd);
|
clearError(tabKey);
|
||||||
return () => {
|
}
|
||||||
window.removeEventListener("mousemove", handleDragMove);
|
return response;
|
||||||
window.removeEventListener("mouseup", handleDragEnd);
|
} catch (e) {
|
||||||
};
|
const errorString = (e as Error).message ?? String(e);
|
||||||
|
if (tabKey !== undefined) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tabKey]: errorString,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}, [isDragging, handleDragMove, handleDragEnd]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastCommand", command);
|
localStorage.setItem("lastCommand", command);
|
||||||
@@ -195,6 +208,31 @@ const App = () => {
|
|||||||
localStorage.setItem("lastArgs", args);
|
localStorage.setItem("lastArgs", args);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
fetch(`${PROXY_SERVER_URL}/config`)
|
fetch(`${PROXY_SERVER_URL}/config`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
@@ -216,79 +254,16 @@ const App = () => {
|
|||||||
rootsRef.current = roots;
|
rootsRef.current = roots;
|
||||||
}, [roots]);
|
}, [roots]);
|
||||||
|
|
||||||
const pushHistory = (request: object, response?: object) => {
|
useEffect(() => {
|
||||||
setRequestHistory((prev) => [
|
if (!window.location.hash) {
|
||||||
...prev,
|
window.location.hash = "resources";
|
||||||
{
|
}
|
||||||
request: JSON.stringify(request),
|
}, []);
|
||||||
response: response !== undefined ? JSON.stringify(response) : undefined,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearError = (tabKey: keyof typeof errors) => {
|
const clearError = (tabKey: keyof typeof errors) => {
|
||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeRequest = async <T extends ZodType<object>>(
|
|
||||||
request: ClientRequest,
|
|
||||||
schema: T,
|
|
||||||
tabKey?: keyof typeof errors,
|
|
||||||
) => {
|
|
||||||
if (!mcpClient) {
|
|
||||||
throw new Error("MCP client not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
abortController.abort("Request timed out");
|
|
||||||
}, DEFAULT_REQUEST_TIMEOUT_MSEC);
|
|
||||||
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await mcpClient.request(request, schema, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
pushHistory(request, response);
|
|
||||||
|
|
||||||
if (tabKey !== undefined) {
|
|
||||||
clearError(tabKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const errorString = (e as Error).message ?? String(e);
|
|
||||||
if (tabKey === undefined) {
|
|
||||||
toast.error(errorString);
|
|
||||||
} else {
|
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[tabKey]: errorString,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendNotification = async (notification: ClientNotification) => {
|
|
||||||
if (!mcpClient) {
|
|
||||||
throw new Error("MCP client not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mcpClient.notification(notification);
|
|
||||||
pushHistory(notification);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error((e as Error).message ?? String(e));
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listResources = async () => {
|
const listResources = async () => {
|
||||||
const response = await makeRequest(
|
const response = await makeRequest(
|
||||||
{
|
{
|
||||||
@@ -391,79 +366,6 @@ const App = () => {
|
|||||||
await sendNotification({ method: "notifications/roots/list_changed" });
|
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectMcpServer = async () => {
|
|
||||||
try {
|
|
||||||
const client = new Client<Request, Notification, Result>(
|
|
||||||
{
|
|
||||||
name: "mcp-inspector",
|
|
||||||
version: "0.0.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
// Support all client capabilities since we're an inspector tool
|
|
||||||
sampling: {},
|
|
||||||
roots: {
|
|
||||||
listChanged: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`);
|
|
||||||
|
|
||||||
backendUrl.searchParams.append("transportType", transportType);
|
|
||||||
if (transportType === "stdio") {
|
|
||||||
backendUrl.searchParams.append("command", command);
|
|
||||||
backendUrl.searchParams.append("args", args);
|
|
||||||
backendUrl.searchParams.append("env", JSON.stringify(env));
|
|
||||||
} else {
|
|
||||||
backendUrl.searchParams.append("url", sseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientTransport = new SSEClientTransport(backendUrl);
|
|
||||||
client.setNotificationHandler(
|
|
||||||
ProgressNotificationSchema,
|
|
||||||
(notification) => {
|
|
||||||
setNotifications((prevNotifications) => [
|
|
||||||
...prevNotifications,
|
|
||||||
notification,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
client.setNotificationHandler(
|
|
||||||
StdErrNotificationSchema,
|
|
||||||
(notification) => {
|
|
||||||
setStdErrNotifications((prevErrorNotifications) => [
|
|
||||||
...prevErrorNotifications,
|
|
||||||
notification,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(clientTransport);
|
|
||||||
|
|
||||||
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
|
||||||
return new Promise<CreateMessageResult>((resolve, reject) => {
|
|
||||||
setPendingSampleRequests((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: nextRequestId.current++, request, resolve, reject },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.setRequestHandler(ListRootsRequestSchema, async () => {
|
|
||||||
return { roots: rootsRef.current };
|
|
||||||
});
|
|
||||||
|
|
||||||
setMcpClient(client);
|
|
||||||
setConnectionStatus("connected");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setConnectionStatus("error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -484,17 +386,42 @@ const App = () => {
|
|||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{mcpClient ? (
|
{mcpClient ? (
|
||||||
<Tabs defaultValue="resources" className="w-full p-4">
|
<Tabs
|
||||||
|
defaultValue={
|
||||||
|
Object.keys(serverCapabilities ?? {}).includes(
|
||||||
|
window.location.hash.slice(1),
|
||||||
|
)
|
||||||
|
? window.location.hash.slice(1)
|
||||||
|
: serverCapabilities?.resources
|
||||||
|
? "resources"
|
||||||
|
: serverCapabilities?.prompts
|
||||||
|
? "prompts"
|
||||||
|
: serverCapabilities?.tools
|
||||||
|
? "tools"
|
||||||
|
: "ping"
|
||||||
|
}
|
||||||
|
className="w-full p-4"
|
||||||
|
onValueChange={(value) => (window.location.hash = value)}
|
||||||
|
>
|
||||||
<TabsList className="mb-4 p-0">
|
<TabsList className="mb-4 p-0">
|
||||||
<TabsTrigger value="resources">
|
<TabsTrigger
|
||||||
|
value="resources"
|
||||||
|
disabled={!serverCapabilities?.resources}
|
||||||
|
>
|
||||||
<Files className="w-4 h-4 mr-2" />
|
<Files className="w-4 h-4 mr-2" />
|
||||||
Resources
|
Resources
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="prompts">
|
<TabsTrigger
|
||||||
|
value="prompts"
|
||||||
|
disabled={!serverCapabilities?.prompts}
|
||||||
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
Prompts
|
Prompts
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tools">
|
<TabsTrigger
|
||||||
|
value="tools"
|
||||||
|
disabled={!serverCapabilities?.tools}
|
||||||
|
>
|
||||||
<Hammer className="w-4 h-4 mr-2" />
|
<Hammer className="w-4 h-4 mr-2" />
|
||||||
Tools
|
Tools
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -518,91 +445,119 @@ const App = () => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ResourcesTab
|
{!serverCapabilities?.resources &&
|
||||||
resources={resources}
|
!serverCapabilities?.prompts &&
|
||||||
resourceTemplates={resourceTemplates}
|
!serverCapabilities?.tools ? (
|
||||||
listResources={() => {
|
<div className="flex items-center justify-center p-4">
|
||||||
clearError("resources");
|
<p className="text-lg text-gray-500">
|
||||||
listResources();
|
The connected server does not support any MCP capabilities
|
||||||
}}
|
</p>
|
||||||
listResourceTemplates={() => {
|
</div>
|
||||||
clearError("resources");
|
) : (
|
||||||
listResourceTemplates();
|
<>
|
||||||
}}
|
<ResourcesTab
|
||||||
readResource={(uri) => {
|
resources={resources}
|
||||||
clearError("resources");
|
resourceTemplates={resourceTemplates}
|
||||||
readResource(uri);
|
listResources={() => {
|
||||||
}}
|
clearError("resources");
|
||||||
selectedResource={selectedResource}
|
listResources();
|
||||||
setSelectedResource={(resource) => {
|
}}
|
||||||
clearError("resources");
|
clearResources={() => {
|
||||||
setSelectedResource(resource);
|
setResources([]);
|
||||||
}}
|
setNextResourceCursor(undefined);
|
||||||
resourceContent={resourceContent}
|
}}
|
||||||
nextCursor={nextResourceCursor}
|
listResourceTemplates={() => {
|
||||||
nextTemplateCursor={nextResourceTemplateCursor}
|
clearError("resources");
|
||||||
error={errors.resources}
|
listResourceTemplates();
|
||||||
/>
|
}}
|
||||||
<PromptsTab
|
clearResourceTemplates={() => {
|
||||||
prompts={prompts}
|
setResourceTemplates([]);
|
||||||
listPrompts={() => {
|
setNextResourceTemplateCursor(undefined);
|
||||||
clearError("prompts");
|
}}
|
||||||
listPrompts();
|
readResource={(uri) => {
|
||||||
}}
|
clearError("resources");
|
||||||
getPrompt={(name, args) => {
|
readResource(uri);
|
||||||
clearError("prompts");
|
}}
|
||||||
getPrompt(name, args);
|
selectedResource={selectedResource}
|
||||||
}}
|
setSelectedResource={(resource) => {
|
||||||
selectedPrompt={selectedPrompt}
|
clearError("resources");
|
||||||
setSelectedPrompt={(prompt) => {
|
setSelectedResource(resource);
|
||||||
clearError("prompts");
|
}}
|
||||||
setSelectedPrompt(prompt);
|
resourceContent={resourceContent}
|
||||||
}}
|
nextCursor={nextResourceCursor}
|
||||||
promptContent={promptContent}
|
nextTemplateCursor={nextResourceTemplateCursor}
|
||||||
nextCursor={nextPromptCursor}
|
error={errors.resources}
|
||||||
error={errors.prompts}
|
/>
|
||||||
/>
|
<PromptsTab
|
||||||
<ToolsTab
|
prompts={prompts}
|
||||||
tools={tools}
|
listPrompts={() => {
|
||||||
listTools={() => {
|
clearError("prompts");
|
||||||
clearError("tools");
|
listPrompts();
|
||||||
listTools();
|
}}
|
||||||
}}
|
clearPrompts={() => {
|
||||||
callTool={(name, params) => {
|
setPrompts([]);
|
||||||
clearError("tools");
|
setNextPromptCursor(undefined);
|
||||||
callTool(name, params);
|
}}
|
||||||
}}
|
getPrompt={(name, args) => {
|
||||||
selectedTool={selectedTool}
|
clearError("prompts");
|
||||||
setSelectedTool={(tool) => {
|
getPrompt(name, args);
|
||||||
clearError("tools");
|
}}
|
||||||
setSelectedTool(tool);
|
selectedPrompt={selectedPrompt}
|
||||||
setToolResult(null);
|
setSelectedPrompt={(prompt) => {
|
||||||
}}
|
clearError("prompts");
|
||||||
toolResult={toolResult}
|
setSelectedPrompt(prompt);
|
||||||
nextCursor={nextToolCursor}
|
}}
|
||||||
error={errors.tools}
|
promptContent={promptContent}
|
||||||
/>
|
nextCursor={nextPromptCursor}
|
||||||
<ConsoleTab />
|
error={errors.prompts}
|
||||||
<PingTab
|
/>
|
||||||
onPingClick={() => {
|
<ToolsTab
|
||||||
void makeRequest(
|
tools={tools}
|
||||||
{
|
listTools={() => {
|
||||||
method: "ping" as const,
|
clearError("tools");
|
||||||
},
|
listTools();
|
||||||
EmptyResultSchema,
|
}}
|
||||||
);
|
clearTools={() => {
|
||||||
}}
|
setTools([]);
|
||||||
/>
|
setNextToolCursor(undefined);
|
||||||
<SamplingTab
|
}}
|
||||||
pendingRequests={pendingSampleRequests}
|
callTool={(name, params) => {
|
||||||
onApprove={handleApproveSampling}
|
clearError("tools");
|
||||||
onReject={handleRejectSampling}
|
callTool(name, params);
|
||||||
/>
|
}}
|
||||||
<RootsTab
|
selectedTool={selectedTool}
|
||||||
roots={roots}
|
setSelectedTool={(tool) => {
|
||||||
setRoots={setRoots}
|
clearError("tools");
|
||||||
onRootsChange={handleRootsChange}
|
setSelectedTool(tool);
|
||||||
/>
|
setToolResult(null);
|
||||||
|
}}
|
||||||
|
toolResult={toolResult}
|
||||||
|
nextCursor={nextToolCursor}
|
||||||
|
error={errors.tools}
|
||||||
|
/>
|
||||||
|
<ConsoleTab />
|
||||||
|
<PingTab
|
||||||
|
onPingClick={() => {
|
||||||
|
void makeRequest(
|
||||||
|
{
|
||||||
|
method: "ping" as const,
|
||||||
|
},
|
||||||
|
EmptyResultSchema,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SamplingTab
|
||||||
|
pendingRequests={pendingSampleRequests}
|
||||||
|
onApprove={handleApproveSampling}
|
||||||
|
onReject={handleRejectSampling}
|
||||||
|
/>
|
||||||
|
<RootsTab
|
||||||
|
roots={roots}
|
||||||
|
setRoots={setRoots}
|
||||||
|
onRootsChange={handleRootsChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
|
|||||||
type ListPaneProps<T> = {
|
type ListPaneProps<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
listItems: () => void;
|
listItems: () => void;
|
||||||
|
clearItems: () => void;
|
||||||
setSelectedItem: (item: T) => void;
|
setSelectedItem: (item: T) => void;
|
||||||
renderItem: (item: T) => React.ReactNode;
|
renderItem: (item: T) => React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,6 +14,7 @@ type ListPaneProps<T> = {
|
|||||||
const ListPane = <T extends object>({
|
const ListPane = <T extends object>({
|
||||||
items,
|
items,
|
||||||
listItems,
|
listItems,
|
||||||
|
clearItems,
|
||||||
setSelectedItem,
|
setSelectedItem,
|
||||||
renderItem,
|
renderItem,
|
||||||
title,
|
title,
|
||||||
@@ -32,6 +34,14 @@ const ListPane = <T extends object>({
|
|||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={clearItems}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
54
client/src/components/OAuthCallback.tsx
Normal file
54
client/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 tokens = await handleOAuthCallback(serverUrl, code);
|
||||||
|
// Store both access and refresh tokens
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token);
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SESSION_KEYS.REFRESH_TOKEN,
|
||||||
|
tokens.refresh_token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
@@ -22,6 +22,7 @@ export type Prompt = {
|
|||||||
const PromptsTab = ({
|
const PromptsTab = ({
|
||||||
prompts,
|
prompts,
|
||||||
listPrompts,
|
listPrompts,
|
||||||
|
clearPrompts,
|
||||||
getPrompt,
|
getPrompt,
|
||||||
selectedPrompt,
|
selectedPrompt,
|
||||||
setSelectedPrompt,
|
setSelectedPrompt,
|
||||||
@@ -31,6 +32,7 @@ const PromptsTab = ({
|
|||||||
}: {
|
}: {
|
||||||
prompts: Prompt[];
|
prompts: Prompt[];
|
||||||
listPrompts: () => void;
|
listPrompts: () => void;
|
||||||
|
clearPrompts: () => void;
|
||||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
selectedPrompt: Prompt | null;
|
selectedPrompt: Prompt | null;
|
||||||
setSelectedPrompt: (prompt: Prompt) => void;
|
setSelectedPrompt: (prompt: Prompt) => void;
|
||||||
@@ -55,6 +57,7 @@ const PromptsTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={prompts}
|
items={prompts}
|
||||||
listItems={listPrompts}
|
listItems={listPrompts}
|
||||||
|
clearItems={clearPrompts}
|
||||||
setSelectedItem={(prompt) => {
|
setSelectedItem={(prompt) => {
|
||||||
setSelectedPrompt(prompt);
|
setSelectedPrompt(prompt);
|
||||||
setPromptArgs({});
|
setPromptArgs({});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const ResourcesTab = ({
|
|||||||
resources,
|
resources,
|
||||||
resourceTemplates,
|
resourceTemplates,
|
||||||
listResources,
|
listResources,
|
||||||
|
clearResources,
|
||||||
listResourceTemplates,
|
listResourceTemplates,
|
||||||
|
clearResourceTemplates,
|
||||||
readResource,
|
readResource,
|
||||||
selectedResource,
|
selectedResource,
|
||||||
setSelectedResource,
|
setSelectedResource,
|
||||||
@@ -28,7 +30,9 @@ const ResourcesTab = ({
|
|||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
resourceTemplates: ResourceTemplate[];
|
resourceTemplates: ResourceTemplate[];
|
||||||
listResources: () => void;
|
listResources: () => void;
|
||||||
|
clearResources: () => void;
|
||||||
listResourceTemplates: () => void;
|
listResourceTemplates: () => void;
|
||||||
|
clearResourceTemplates: () => void;
|
||||||
readResource: (uri: string) => void;
|
readResource: (uri: string) => void;
|
||||||
selectedResource: Resource | null;
|
selectedResource: Resource | null;
|
||||||
setSelectedResource: (resource: Resource | null) => void;
|
setSelectedResource: (resource: Resource | null) => void;
|
||||||
@@ -68,6 +72,7 @@ const ResourcesTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={resources}
|
items={resources}
|
||||||
listItems={listResources}
|
listItems={listResources}
|
||||||
|
clearItems={clearResources}
|
||||||
setSelectedItem={(resource) => {
|
setSelectedItem={(resource) => {
|
||||||
setSelectedResource(resource);
|
setSelectedResource(resource);
|
||||||
readResource(resource.uri);
|
readResource(resource.uri);
|
||||||
@@ -90,6 +95,7 @@ const ResourcesTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={resourceTemplates}
|
items={resourceTemplates}
|
||||||
listItems={listResourceTemplates}
|
listItems={listResourceTemplates}
|
||||||
|
clearItems={clearResourceTemplates}
|
||||||
setSelectedItem={(template) => {
|
setSelectedItem={(template) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setSelectedResource(null);
|
setSelectedResource(null);
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Play, ChevronDown, ChevronRight } from "lucide-react";
|
import {
|
||||||
|
Play,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CircleHelp,
|
||||||
|
Bug,
|
||||||
|
Github,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +56,7 @@ const Sidebar = ({
|
|||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
const [theme, setTheme] = useTheme();
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
|
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||||
@@ -86,6 +96,7 @@ const Sidebar = ({
|
|||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -94,6 +105,7 @@ const Sidebar = ({
|
|||||||
placeholder="Arguments (space-separated)"
|
placeholder="Arguments (space-separated)"
|
||||||
value={args}
|
value={args}
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
onChange={(e) => setArgs(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -104,6 +116,7 @@ const Sidebar = ({
|
|||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={sseUrl}
|
value={sseUrl}
|
||||||
onChange={(e) => setSseUrl(e.target.value)}
|
onChange={(e) => setSseUrl(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,19 +137,44 @@ const Sidebar = ({
|
|||||||
{showEnvVars && (
|
{showEnvVars && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(env).map(([key, value], idx) => (
|
{Object.entries(env).map(([key, value], idx) => (
|
||||||
<div key={idx} className="grid grid-cols-[1fr,auto] gap-2">
|
<div key={idx} className="space-y-2 pb-4">
|
||||||
<div className="space-y-1">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const newKey = e.target.value;
|
||||||
const newEnv = { ...env };
|
const newEnv = { ...env };
|
||||||
delete newEnv[key];
|
delete newEnv[key];
|
||||||
newEnv[e.target.value] = value;
|
newEnv[newKey] = value;
|
||||||
setEnv(newEnv);
|
setEnv(newEnv);
|
||||||
|
setShownEnvVars((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
next.add(newKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { [key]: _removed, ...rest } = env;
|
||||||
|
setEnv(rest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
type={shownEnvVars.has(key) ? "text" : "password"}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -144,25 +182,47 @@ const Sidebar = ({
|
|||||||
newEnv[key] = e.target.value;
|
newEnv[key] = e.target.value;
|
||||||
setEnv(newEnv);
|
setEnv(newEnv);
|
||||||
}}
|
}}
|
||||||
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
setShownEnvVars((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
shownEnvVars.has(key) ? "Hide value" : "Show value"
|
||||||
|
}
|
||||||
|
aria-pressed={shownEnvVars.has(key)}
|
||||||
|
title={
|
||||||
|
shownEnvVars.has(key) ? "Hide value" : "Show value"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shownEnvVars.has(key) ? (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { [key]: removed, ...rest } = env;
|
|
||||||
setEnv(rest);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="w-full mt-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const key = "";
|
||||||
const newEnv = { ...env };
|
const newEnv = { ...env };
|
||||||
newEnv[""] = "";
|
newEnv[key] = "";
|
||||||
setEnv(newEnv);
|
setEnv(newEnv);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -220,14 +280,14 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Select
|
||||||
value={theme}
|
value={theme}
|
||||||
onValueChange={(value: string) =>
|
onValueChange={(value: string) =>
|
||||||
setTheme(value as "system" | "light" | "dark")
|
setTheme(value as "system" | "light" | "dark")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[120px]" id="theme-select">
|
<SelectTrigger className="w-[100px]" id="theme-select">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -236,6 +296,39 @@ const Sidebar = ({
|
|||||||
<SelectItem value="dark">Dark</SelectItem>
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<a
|
||||||
|
href="https://modelcontextprotocol.io/docs/tools/inspector"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" title="Inspector Documentation">
|
||||||
|
<CircleHelp className="w-4 h-4 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://modelcontextprotocol.io/docs/tools/debugging"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" title="Debugging Guide">
|
||||||
|
<Bug className="w-4 h-4 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/modelcontextprotocol/inspector"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
title="Report bugs or contribute on GitHub"
|
||||||
|
>
|
||||||
|
<Github className="w-4 h-4 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AlertCircle, Send } from "lucide-react";
|
import { AlertCircle, Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
|
|
||||||
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
@@ -18,6 +18,7 @@ import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
|||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
listTools,
|
listTools,
|
||||||
|
clearTools,
|
||||||
callTool,
|
callTool,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
@@ -27,14 +28,18 @@ const ToolsTab = ({
|
|||||||
}: {
|
}: {
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
callTool: (name: string, params: Record<string, unknown>) => void;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: CompatibilityCallToolResult | null;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
nextCursor: ListToolsResult["nextCursor"];
|
nextCursor: ListToolsResult["nextCursor"];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
setParams({});
|
||||||
|
}, [selectedTool]);
|
||||||
|
|
||||||
const renderToolResult = () => {
|
const renderToolResult = () => {
|
||||||
if (!toolResult) return null;
|
if (!toolResult) return null;
|
||||||
@@ -50,7 +55,7 @@ const ToolsTab = ({
|
|||||||
</pre>
|
</pre>
|
||||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||||
{parsedResult.error.errors.map((error, idx) => (
|
{parsedResult.error.errors.map((error, idx) => (
|
||||||
<pre
|
<pre
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
|
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
|
||||||
>
|
>
|
||||||
@@ -82,11 +87,20 @@ const ToolsTab = ({
|
|||||||
className="max-w-full h-auto"
|
className="max-w-full h-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === "resource" && (
|
{item.type === "resource" &&
|
||||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
|
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||||
{JSON.stringify(item.resource, null, 2)}
|
<audio
|
||||||
</pre>
|
controls
|
||||||
)}
|
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<p>Your browser does not support audio playback</p>
|
||||||
|
</audio>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
|
||||||
|
{JSON.stringify(item.resource, null, 2)}
|
||||||
|
</pre>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -95,7 +109,7 @@ const ToolsTab = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
|
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
|
||||||
{JSON.stringify(toolResult.toolResult, null, 2)}
|
{JSON.stringify(toolResult.toolResult, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</>
|
</>
|
||||||
@@ -108,6 +122,10 @@ const ToolsTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={tools}
|
items={tools}
|
||||||
listItems={listTools}
|
listItems={listTools}
|
||||||
|
clearItems={() => {
|
||||||
|
clearTools();
|
||||||
|
setSelectedTool(null);
|
||||||
|
}}
|
||||||
setSelectedItem={setSelectedTool}
|
setSelectedItem={setSelectedTool}
|
||||||
renderItem={(tool) => (
|
renderItem={(tool) => (
|
||||||
<>
|
<>
|
||||||
@@ -165,6 +183,30 @@ const ToolsTab = ({
|
|||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
) : /* @ts-expect-error value type is currently unknown */
|
||||||
|
value.type === "object" ? (
|
||||||
|
<Textarea
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
// @ts-expect-error value type is currently unknown
|
||||||
|
placeholder={value.description}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.target.value);
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: parsed,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// If invalid JSON, store as string - will be validated on submit
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: e.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
// @ts-expect-error value type is currently unknown
|
// @ts-expect-error value type is currently unknown
|
||||||
|
|||||||
134
client/src/lib/auth.ts
Normal file
134
client/src/lib/auth.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import pkceChallenge from "pkce-challenge";
|
||||||
|
import { SESSION_KEYS } from "./constants";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const OAuthMetadataSchema = z.object({
|
||||||
|
authorization_endpoint: z.string(),
|
||||||
|
token_endpoint: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
|
||||||
|
|
||||||
|
export const OAuthTokensSchema = z.object({
|
||||||
|
access_token: z.string(),
|
||||||
|
refresh_token: z.string().optional(),
|
||||||
|
expires_in: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
const validatedMetadata = OAuthMetadataSchema.parse({
|
||||||
|
authorization_endpoint: metadata.authorization_endpoint,
|
||||||
|
token_endpoint: metadata.token_endpoint,
|
||||||
|
});
|
||||||
|
return validatedMetadata;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("OAuth metadata discovery failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default endpoints
|
||||||
|
const baseUrl = new URL(serverUrl);
|
||||||
|
const defaultMetadata = {
|
||||||
|
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
|
||||||
|
token_endpoint: new URL("/token", baseUrl).toString(),
|
||||||
|
};
|
||||||
|
return OAuthMetadataSchema.parse(defaultMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OAuthTokens> {
|
||||||
|
// 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 tokens = await response.json();
|
||||||
|
return OAuthTokensSchema.parse(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(
|
||||||
|
serverUrl: string,
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const refreshToken = sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("No refresh token available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||||
|
|
||||||
|
const response = await fetch(metadata.token_endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
return OAuthTokensSchema.parse(tokens);
|
||||||
|
}
|
||||||
7
client/src/lib/constants.ts
Normal file
7
client/src/lib/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// OAuth-related session storage keys
|
||||||
|
export const SESSION_KEYS = {
|
||||||
|
CODE_VERIFIER: "mcp_code_verifier",
|
||||||
|
SERVER_URL: "mcp_server_url",
|
||||||
|
ACCESS_TOKEN: "mcp_access_token",
|
||||||
|
REFRESH_TOKEN: "mcp_refresh_token",
|
||||||
|
} as const;
|
||||||
273
client/src/lib/hooks/useConnection.ts
Normal file
273
client/src/lib/hooks/useConnection.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import {
|
||||||
|
SSEClientTransport,
|
||||||
|
SseError,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
|
import {
|
||||||
|
ClientNotification,
|
||||||
|
ClientRequest,
|
||||||
|
CreateMessageRequestSchema,
|
||||||
|
ListRootsRequestSchema,
|
||||||
|
ProgressNotificationSchema,
|
||||||
|
Request,
|
||||||
|
Result,
|
||||||
|
ServerCapabilities,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { startOAuthFlow, refreshAccessToken } from "../auth";
|
||||||
|
import { SESSION_KEYS } from "../constants";
|
||||||
|
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
||||||
|
|
||||||
|
interface UseConnectionOptions {
|
||||||
|
transportType: "stdio" | "sse";
|
||||||
|
command: string;
|
||||||
|
args: string;
|
||||||
|
sseUrl: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
proxyServerUrl: string;
|
||||||
|
requestTimeout?: number;
|
||||||
|
onNotification?: (notification: Notification) => void;
|
||||||
|
onStdErrNotification?: (notification: Notification) => void;
|
||||||
|
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
|
||||||
|
getRoots?: () => any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnection({
|
||||||
|
transportType,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
sseUrl,
|
||||||
|
env,
|
||||||
|
proxyServerUrl,
|
||||||
|
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
|
||||||
|
onNotification,
|
||||||
|
onStdErrNotification,
|
||||||
|
onPendingRequest,
|
||||||
|
getRoots,
|
||||||
|
}: UseConnectionOptions) {
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<
|
||||||
|
"disconnected" | "connected" | "error"
|
||||||
|
>("disconnected");
|
||||||
|
const [serverCapabilities, setServerCapabilities] =
|
||||||
|
useState<ServerCapabilities | null>(null);
|
||||||
|
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
||||||
|
const [requestHistory, setRequestHistory] = useState<
|
||||||
|
{ request: string; response?: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const pushHistory = (request: object, response?: object) => {
|
||||||
|
setRequestHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
request: JSON.stringify(request),
|
||||||
|
response: response !== undefined ? JSON.stringify(response) : undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
|
request: ClientRequest,
|
||||||
|
schema: T,
|
||||||
|
) => {
|
||||||
|
if (!mcpClient) {
|
||||||
|
throw new Error("MCP client not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
abortController.abort("Request timed out");
|
||||||
|
}, requestTimeout);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await mcpClient.request(request, schema, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
pushHistory(request, response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
pushHistory(request, { error: errorMessage });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const errorString = (e as Error).message ?? String(e);
|
||||||
|
toast.error(errorString);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendNotification = async (notification: ClientNotification) => {
|
||||||
|
if (!mcpClient) {
|
||||||
|
throw new Error("MCP client not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mcpClient.notification(notification);
|
||||||
|
pushHistory(notification);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error).message ?? String(e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initiateOAuthFlow = async () => {
|
||||||
|
sessionStorage.removeItem(SESSION_KEYS.ACCESS_TOKEN);
|
||||||
|
sessionStorage.removeItem(SESSION_KEYS.REFRESH_TOKEN);
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
||||||
|
const redirectUrl = await startOAuthFlow(sseUrl);
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTokenRefresh = async () => {
|
||||||
|
try {
|
||||||
|
const tokens = await refreshAccessToken(sseUrl);
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token);
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SESSION_KEYS.REFRESH_TOKEN,
|
||||||
|
tokens.refresh_token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tokens.access_token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh failed:", error);
|
||||||
|
await initiateOAuthFlow();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthError = async (error: unknown) => {
|
||||||
|
if (error instanceof SseError && error.code === 401) {
|
||||||
|
if (sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN)) {
|
||||||
|
try {
|
||||||
|
await handleTokenRefresh();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh failed:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await initiateOAuthFlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = async (_e?: unknown, retryCount: number = 0) => {
|
||||||
|
try {
|
||||||
|
const client = new Client<Request, Notification, Result>(
|
||||||
|
{
|
||||||
|
name: "mcp-inspector",
|
||||||
|
version: "0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
sampling: {},
|
||||||
|
roots: {
|
||||||
|
listChanged: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const backendUrl = new URL(`${proxyServerUrl}/sse`);
|
||||||
|
|
||||||
|
backendUrl.searchParams.append("transportType", transportType);
|
||||||
|
if (transportType === "stdio") {
|
||||||
|
backendUrl.searchParams.append("command", command);
|
||||||
|
backendUrl.searchParams.append("args", args);
|
||||||
|
backendUrl.searchParams.append("env", JSON.stringify(env));
|
||||||
|
} else {
|
||||||
|
backendUrl.searchParams.append("url", sseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
ProgressNotificationSchema,
|
||||||
|
onNotification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStdErrNotification) {
|
||||||
|
client.setNotificationHandler(
|
||||||
|
StdErrNotificationSchema,
|
||||||
|
onStdErrNotification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(clientTransport);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to connect to MCP server:", error);
|
||||||
|
const shouldRetry = await handleAuthError(error);
|
||||||
|
if (shouldRetry) {
|
||||||
|
return connect(undefined, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof SseError && error.code === 401) {
|
||||||
|
// Don't set error state if we're about to redirect for auth
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = client.getServerCapabilities();
|
||||||
|
setServerCapabilities(capabilities ?? null);
|
||||||
|
|
||||||
|
if (onPendingRequest) {
|
||||||
|
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
onPendingRequest(request, resolve, reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getRoots) {
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, async () => {
|
||||||
|
return { roots: getRoots() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMcpClient(client);
|
||||||
|
setConnectionStatus("connected");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setConnectionStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionStatus,
|
||||||
|
serverCapabilities,
|
||||||
|
mcpClient,
|
||||||
|
requestHistory,
|
||||||
|
makeRequest,
|
||||||
|
sendNotification,
|
||||||
|
connect,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
client/src/lib/hooks/useDraggablePane.ts
Normal file
53
client/src/lib/hooks/useDraggablePane.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useDraggablePane(initialHeight: number) {
|
||||||
|
const [height, setHeight] = useState(initialHeight);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragStartY = useRef<number>(0);
|
||||||
|
const dragStartHeight = useRef<number>(0);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStartY.current = e.clientY;
|
||||||
|
dragStartHeight.current = height;
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
},
|
||||||
|
[height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const deltaY = dragStartY.current - e.clientY;
|
||||||
|
const newHeight = Math.max(
|
||||||
|
100,
|
||||||
|
Math.min(800, dragStartHeight.current + deltaY),
|
||||||
|
);
|
||||||
|
setHeight(newHeight);
|
||||||
|
},
|
||||||
|
[isDragging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleDragMove);
|
||||||
|
window.addEventListener("mouseup", handleDragEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleDragMove);
|
||||||
|
window.removeEventListener("mouseup", handleDragEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging, handleDragMove, handleDragEnd]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
isDragging,
|
||||||
|
handleDragStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from "react-toastify";
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
@@ -14,8 +15,8 @@ export default defineConfig({
|
|||||||
minify: false,
|
minify: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: undefined
|
manualChunks: undefined,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
109
package-lock.json
generated
109
package-lock.json
generated
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.2.7",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.2.7",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
"server"
|
"server"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-client": "0.2.7",
|
"@modelcontextprotocol/inspector-client": "0.3.0",
|
||||||
"@modelcontextprotocol/inspector-server": "0.2.7",
|
"@modelcontextprotocol/inspector-server": "0.3.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"shell-quote": "^1.8.2",
|
||||||
"spawn-rx": "^5.1.0",
|
"spawn-rx": "^5.1.0",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
@@ -24,15 +25,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"prettier": "3.3.3"
|
"prettier": "3.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.2.7",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
|
"pkce-challenge": "^4.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-toastify": "^10.0.6",
|
"react-toastify": "^10.0.6",
|
||||||
@@ -1203,14 +1206,31 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.0.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.1.tgz",
|
||||||
"integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==",
|
"integrity": "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
"raw-body": "^3.0.0",
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
@@ -2256,13 +2276,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/express": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
||||||
@@ -2394,6 +2407,13 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/shell-quote": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||||
@@ -3680,13 +3700,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource-parser": {
|
||||||
"version": "2.0.2",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz",
|
||||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
"integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
@@ -4927,6 +4947,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.49",
|
"version": "8.4.49",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||||
@@ -5689,10 +5718,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
|
||||||
"integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
|
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
@@ -6268,9 +6300,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.11",
|
"version": "5.4.12",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.12.tgz",
|
||||||
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
|
"integrity": "sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6916,22 +6948,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.23.8",
|
"version": "3.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
|
||||||
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
|
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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": {
|
"server": {
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.2.7",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"eventsource": "^2.0.2",
|
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -6941,7 +6981,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/eventsource": "^1.1.15",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.2.7",
|
"version": "0.4.1",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -33,14 +33,16 @@
|
|||||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-client": "0.2.7",
|
"@modelcontextprotocol/inspector-client": "0.4.1",
|
||||||
"@modelcontextprotocol/inspector-server": "0.2.7",
|
"@modelcontextprotocol/inspector-server": "0.4.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"shell-quote": "^1.8.2",
|
||||||
"spawn-rx": "^5.1.0",
|
"spawn-rx": "^5.1.0",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"prettier": "3.3.3"
|
"prettier": "3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.2.7",
|
"version": "0.4.1",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
@@ -20,16 +20,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/eventsource": "^1.1.15",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"eventsource": "^2.0.2",
|
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import EventSource from "eventsource";
|
|
||||||
import { parseArgs } from "node:util";
|
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 {
|
import {
|
||||||
StdioClientTransport,
|
StdioClientTransport,
|
||||||
getDefaultEnvironment,
|
getDefaultEnvironment,
|
||||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import mcpProxy from "./mcpProxy.js";
|
|
||||||
import { findActualExecutable } from "spawn-rx";
|
import { findActualExecutable } from "spawn-rx";
|
||||||
|
import mcpProxy from "./mcpProxy.js";
|
||||||
|
|
||||||
// Polyfill EventSource for an SSE client in Node.js
|
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(global as any).EventSource = EventSource;
|
const defaultEnvironment = {
|
||||||
|
...getDefaultEnvironment(),
|
||||||
|
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
@@ -31,21 +37,21 @@ app.use(cors());
|
|||||||
|
|
||||||
let webAppTransports: SSEServerTransport[] = [];
|
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);
|
console.log("Query parameters:", query);
|
||||||
|
|
||||||
const transportType = query.transportType as string;
|
const transportType = query.transportType as string;
|
||||||
|
|
||||||
if (transportType === "stdio") {
|
if (transportType === "stdio") {
|
||||||
const command = query.command as string;
|
const command = query.command as string;
|
||||||
const origArgs = (query.args as string).split(/\s+/);
|
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||||
const env = query.env ? JSON.parse(query.env as string) : undefined;
|
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||||
|
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
|
||||||
|
|
||||||
const { cmd, args } = findActualExecutable(command, origArgs);
|
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||||
|
|
||||||
console.log(
|
console.log(`Stdio transport: command=${cmd}, args=${args}`);
|
||||||
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: cmd,
|
command: cmd,
|
||||||
@@ -60,9 +66,26 @@ const createTransport = async (query: express.Request["query"]) => {
|
|||||||
return transport;
|
return transport;
|
||||||
} else if (transportType === "sse") {
|
} else if (transportType === "sse") {
|
||||||
const url = query.url as string;
|
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();
|
await transport.start();
|
||||||
|
|
||||||
console.log("Connected to SSE transport");
|
console.log("Connected to SSE transport");
|
||||||
@@ -77,7 +100,21 @@ app.get("/sse", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
console.log("New SSE connection");
|
console.log("New SSE connection");
|
||||||
|
|
||||||
const backingServerTransport = await createTransport(req.query);
|
let backingServerTransport;
|
||||||
|
try {
|
||||||
|
backingServerTransport = await createTransport(req);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SseError && 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");
|
console.log("Connected MCP client to backing server transport");
|
||||||
|
|
||||||
@@ -104,9 +141,6 @@ app.get("/sse", async (req, res) => {
|
|||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: webAppTransport,
|
transportToClient: webAppTransport,
|
||||||
transportToServer: backingServerTransport,
|
transportToServer: backingServerTransport,
|
||||||
onerror: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Set up MCP proxy");
|
console.log("Set up MCP proxy");
|
||||||
@@ -135,8 +169,6 @@ app.post("/message", async (req, res) => {
|
|||||||
|
|
||||||
app.get("/config", (req, res) => {
|
app.get("/config", (req, res) => {
|
||||||
try {
|
try {
|
||||||
const defaultEnvironment = getDefaultEnvironment();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
defaultEnvironment,
|
defaultEnvironment,
|
||||||
defaultCommand: values.env,
|
defaultCommand: values.env,
|
||||||
@@ -149,4 +181,16 @@ app.get("/config", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {});
|
|
||||||
|
try {
|
||||||
|
const server = app.listen(PORT);
|
||||||
|
|
||||||
|
server.on("listening", () => {
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === "string" ? addr : addr?.port;
|
||||||
|
console.log(`Proxy server listening on port ${port}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start server:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
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({
|
export default function mcpProxy({
|
||||||
transportToClient,
|
transportToClient,
|
||||||
transportToServer,
|
transportToServer,
|
||||||
onerror,
|
|
||||||
}: {
|
}: {
|
||||||
transportToClient: Transport;
|
transportToClient: Transport;
|
||||||
transportToServer: Transport;
|
transportToServer: Transport;
|
||||||
onerror: (error: Error) => void;
|
|
||||||
}) {
|
}) {
|
||||||
let transportToClientClosed = false;
|
let transportToClientClosed = false;
|
||||||
let transportToServerClosed = false;
|
let transportToServerClosed = false;
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (message) => {
|
||||||
transportToServer.send(message).catch(onerror);
|
transportToServer.send(message).catch(onServerError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToServer.onmessage = (message) => {
|
transportToServer.onmessage = (message) => {
|
||||||
transportToClient.send(message).catch(onerror);
|
transportToClient.send(message).catch(onClientError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToClient.onclose = () => {
|
transportToClient.onclose = () => {
|
||||||
@@ -26,7 +32,7 @@ export default function mcpProxy({
|
|||||||
}
|
}
|
||||||
|
|
||||||
transportToClientClosed = true;
|
transportToClientClosed = true;
|
||||||
transportToServer.close().catch(onerror);
|
transportToServer.close().catch(onServerError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToServer.onclose = () => {
|
transportToServer.onclose = () => {
|
||||||
@@ -34,10 +40,9 @@ export default function mcpProxy({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transportToServerClosed = true;
|
transportToServerClosed = true;
|
||||||
|
transportToClient.close().catch(onClientError);
|
||||||
transportToClient.close().catch(onerror);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToClient.onerror = onerror;
|
transportToClient.onerror = onClientError;
|
||||||
transportToServer.onerror = onerror;
|
transportToServer.onerror = onServerError;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user