Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027eb02422 | ||
|
|
b116264f90 | ||
|
|
290d5ab49e | ||
|
|
826ce37d2c | ||
|
|
7a56a7200c | ||
|
|
1eba99c531 | ||
|
|
13ae2b5659 | ||
|
|
db1b5cbc45 | ||
|
|
989efb2204 | ||
|
|
717c394d3b | ||
|
|
8267e514ce | ||
|
|
18438dbdd0 | ||
|
|
07577fc94b | ||
|
|
88984c7bc7 | ||
|
|
b4870b3da3 | ||
|
|
19ee9fa86a | ||
|
|
e28a64c932 | ||
|
|
02479d3ea9 | ||
|
|
c3ece186a4 | ||
|
|
4201b31a24 | ||
|
|
50638806cb | ||
|
|
27880974a2 | ||
|
|
266e8bec98 | ||
|
|
ed59974d65 | ||
|
|
8e06165d73 | ||
|
|
e22d3c76bf | ||
|
|
02f53005db | ||
|
|
3893807841 | ||
|
|
7b40aed43b | ||
|
|
0f304a37ae | ||
|
|
39970db604 | ||
|
|
f505ae3d5a | ||
|
|
89ee2b1b93 | ||
|
|
450405733a | ||
|
|
d4df126112 | ||
|
|
7065d70e34 | ||
|
|
ad004bc2f7 | ||
|
|
db6494353c | ||
|
|
3408be3e55 | ||
|
|
406828ade2 | ||
|
|
44d07b964c | ||
|
|
5b2d54ae3b | ||
|
|
f7312ab331 | ||
|
|
59e7639d39 | ||
|
|
133b785f79 | ||
|
|
f6860a88f9 | ||
|
|
b3194ac56e | ||
|
|
7c57e823bd | ||
|
|
b8e73886dd | ||
|
|
2a1536d2ab | ||
|
|
348cff9872 | ||
|
|
beee38387c | ||
|
|
7b3dff68c0 | ||
|
|
d9df5ff860 | ||
|
|
5b451a7cfe | ||
|
|
7f713fe40e | ||
|
|
fa723abbe0 | ||
|
|
410a6f33dc | ||
|
|
b324378b2c | ||
|
|
e427f7bca5 | ||
|
|
c66feff37d | ||
|
|
9b624e8c87 | ||
|
|
ba99638f48 | ||
|
|
f4aefa2706 | ||
|
|
e9a50adde7 | ||
|
|
eb6af47b21 | ||
|
|
6d930ecae7 | ||
|
|
9c3fee1442 | ||
|
|
688752ea77 | ||
|
|
1b13b574f8 | ||
|
|
95bbd60a38 | ||
|
|
96ba6fd531 | ||
|
|
8592cf2d07 | ||
|
|
dd47b574b3 | ||
|
|
b4ae1327b5 | ||
|
|
b5762d53fd | ||
|
|
7957d9f577 | ||
|
|
4c89aed4d9 | ||
|
|
79547143a8 | ||
|
|
d438760e36 | ||
|
|
d0ad677784 | ||
|
|
98b26e9d06 | ||
|
|
d007f92302 | ||
|
|
6a3d901a72 | ||
|
|
58ad8103f7 | ||
|
|
ee2c67e1af | ||
|
|
7c89a01c99 | ||
|
|
fb3d89c6e3 | ||
|
|
4b3bb5f34e | ||
|
|
1d4e8885db | ||
|
|
a87bd17f51 | ||
|
|
afe14bc883 | ||
|
|
04faff4757 | ||
|
|
a4469f7895 | ||
|
|
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 | ||
|
|
f8052dfcda | ||
|
|
98e6f0e5ec | ||
|
|
ec150eb8b4 | ||
|
|
052de8690d | ||
|
|
a976aefb39 | ||
|
|
5a5873277c | ||
|
|
715936d747 | ||
|
|
d973f58bef | ||
|
|
88ed9c088d | ||
|
|
1797fbfba8 | ||
|
|
8f4013c42c | ||
|
|
1abb7ca59c | ||
|
|
dfb36e1792 | ||
|
|
ffc29663c8 | ||
|
|
53226dd391 | ||
|
|
243ee1a6b5 | ||
|
|
dc49d46baa | ||
|
|
ef32a8f289 | ||
|
|
54e9957ec5 | ||
|
|
c78b0fbed6 | ||
|
|
7edde5001b | ||
|
|
0fa56e14d9 | ||
|
|
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 | ||
|
|
945299181d | ||
|
|
79344bd495 | ||
|
|
295ccac27e | ||
|
|
f3f424f21e | ||
|
|
6b6eeb8dcd | ||
|
|
3110cf9343 | ||
|
|
2c04fa31e8 | ||
|
|
e700bc713a | ||
|
|
bea86af65b | ||
|
|
68a6130b17 | ||
|
|
853a3b4faf | ||
|
|
6f62066d34 | ||
|
|
c770d217e7 | ||
|
|
98470a12f9 | ||
|
|
a00564fafa | ||
|
|
62546dec58 | ||
|
|
886ac5fc7b | ||
|
|
722df4d798 | ||
|
|
407e304585 | ||
|
|
60578314aa | ||
|
|
3c4cb17d09 | ||
|
|
fbac5b78bc | ||
|
|
f876b1ec0d | ||
|
|
aecfa21d47 | ||
|
|
a3d542c0a3 | ||
|
|
2b79b6ffd4 | ||
|
|
1f28b4474c | ||
|
|
d69d67cb64 | ||
|
|
7792070d81 | ||
|
|
34a2843756 | ||
|
|
2a34770959 | ||
|
|
6b674b0827 | ||
|
|
ca8db1f417 | ||
|
|
eb4456d1e3 | ||
|
|
780b92274d | ||
|
|
b825784b8f | ||
|
|
52c7e98055 | ||
|
|
4862aa7c1d | ||
|
|
561ea91504 | ||
|
|
7c2be8d139 | ||
|
|
97d469911e | ||
|
|
11b891c6ca | ||
|
|
5139e723a4 | ||
|
|
fc5b79c9a6 | ||
|
|
47a87e1884 |
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -14,6 +14,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check formatting
|
||||
run: npx prettier --check .
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
packages
|
||||
server/build
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
|
||||
33
CLAUDE.md
Normal file
33
CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# MCP Inspector Development Guide
|
||||
|
||||
## Build Commands
|
||||
|
||||
- Build all: `npm run build`
|
||||
- Build client: `npm run build-client`
|
||||
- Build server: `npm run build-server`
|
||||
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
|
||||
- Format code: `npm run prettier-fix`
|
||||
- Client lint: `cd client && npm run lint`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- Use TypeScript with proper type annotations
|
||||
- Follow React functional component patterns with hooks
|
||||
- Use ES modules (import/export) not CommonJS
|
||||
- Use Prettier for formatting (auto-formatted on commit)
|
||||
- Follow existing naming conventions:
|
||||
- camelCase for variables and functions
|
||||
- PascalCase for component names and types
|
||||
- kebab-case for file names
|
||||
- Use async/await for asynchronous operations
|
||||
- Implement proper error handling with try/catch blocks
|
||||
- Use Tailwind CSS for styling in the client
|
||||
- Keep components small and focused on a single responsibility
|
||||
|
||||
## Project Organization
|
||||
|
||||
The project is organized as a monorepo with workspaces:
|
||||
|
||||
- `client/`: React frontend with Vite, TypeScript and Tailwind
|
||||
- `server/`: Express backend with TypeScript
|
||||
- `bin/`: CLI scripts
|
||||
58
README.md
58
README.md
@@ -2,16 +2,64 @@
|
||||
|
||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||
|
||||
It can be run easily from `npx`. For example, in a folder where there's a built JavaScript server at `build/index.js`:
|
||||

|
||||
|
||||
```
|
||||
npx @modelcontextprotocol/inspector build/index.js
|
||||
## Running the Inspector
|
||||
|
||||
### From an MCP server repository
|
||||
|
||||
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector node build/index.js
|
||||
```
|
||||
|
||||
You can also pass arguments along to the 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
|
||||
# Pass arguments only
|
||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
|
||||
|
||||
# Pass environment variables only
|
||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
|
||||
|
||||
# Pass both environment variables and arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
|
||||
|
||||
# Use -- to separate inspector flags from server arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
||||
```
|
||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node 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
|
||||
|
||||
If you're working on the inspector itself:
|
||||
|
||||
Development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> **Note for Windows users:**
|
||||
> On Windows, use the following command instead:
|
||||
>
|
||||
> ```bash
|
||||
> npm run dev:windows
|
||||
> ```
|
||||
|
||||
Production mode:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
153
bin/cli.js
153
bin/cli.js
@@ -1,65 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { join, dirname } from "path";
|
||||
import { resolve, dirname } from "path";
|
||||
import { spawnPromise } from "spawn-rx";
|
||||
import { fileURLToPath } from "url";
|
||||
import concurrently from "concurrently";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Get command line arguments
|
||||
const [, , command, ...mcpServerArgs] = process.argv;
|
||||
|
||||
const inspectorServerPath = join(__dirname, "../server/build/index.js");
|
||||
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = join(__dirname, "../client/bin/cli.js");
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
|
||||
function escapeArg(arg) {
|
||||
if (arg.includes(" ") || arg.includes("'") || arg.includes('"')) {
|
||||
return `\\"${arg.replace(/"/g, '\\\\\\"')}\\"`;
|
||||
}
|
||||
return arg;
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const serverCommand = [
|
||||
`node`,
|
||||
inspectorServerPath,
|
||||
command ? `--env ${escapeArg(command)}` : "",
|
||||
mcpServerArgs.length
|
||||
? `--args="${mcpServerArgs.map(escapeArg).join(" ")}"`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
async function main() {
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const envVars = {};
|
||||
const mcpServerArgs = [];
|
||||
let command = null;
|
||||
let parsingFlags = true;
|
||||
|
||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "";
|
||||
const SERVER_PORT = process.env.SERVER_PORT ?? "";
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
const { result } = concurrently(
|
||||
[
|
||||
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(
|
||||
__dirname,
|
||||
"..",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
);
|
||||
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"client",
|
||||
"bin",
|
||||
"cli.js",
|
||||
);
|
||||
|
||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173";
|
||||
const SERVER_PORT = process.env.SERVER_PORT ?? "3000";
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
|
||||
const abort = new AbortController();
|
||||
|
||||
let cancelled = false;
|
||||
process.on("SIGINT", () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(command ? [`--env`, command] : []),
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
command: `PORT=${SERVER_PORT} ${serverCommand}`,
|
||||
name: "server",
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
{
|
||||
command: `PORT=${CLIENT_PORT} node ${inspectorClientPath}`,
|
||||
name: "client",
|
||||
},
|
||||
],
|
||||
{
|
||||
prefix: "name",
|
||||
killOthers: ["failure", "success"],
|
||||
restartTries: 3,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT || 5173} 🚀`,
|
||||
);
|
||||
const client = spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
|
||||
result.catch((err) => {
|
||||
console.error("An error occurred:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
// Make sure our server/client didn't immediately fail
|
||||
await Promise.any([server, client, delay(2 * 1000)]);
|
||||
const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`;
|
||||
console.log(
|
||||
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} 🚀`,
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.any([server, client]);
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((_) => process.exit(0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const distPath = join(__dirname, "../dist");
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
return handler(request, response, { public: distPath });
|
||||
return handler(request, response, {
|
||||
public: distPath,
|
||||
rewrites: [{ source: "/**", destination: "/index.html" }],
|
||||
});
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 5173;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.1",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -21,17 +21,25 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"lucide-react": "^0.447.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"pkce-challenge": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-toastify": "^10.0.6",
|
||||
"serve-handler": "^6.1.6",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
CompatibilityCallToolResult,
|
||||
CompatibilityCallToolResultSchema,
|
||||
CreateMessageRequestSchema,
|
||||
CreateMessageResult,
|
||||
EmptyResultSchema,
|
||||
GetPromptResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
ProgressNotificationSchema,
|
||||
ReadResourceResultSchema,
|
||||
Request,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Result,
|
||||
Root,
|
||||
ServerNotification,
|
||||
Tool,
|
||||
} 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 {
|
||||
Notification,
|
||||
StdErrNotification,
|
||||
StdErrNotificationSchema
|
||||
} from "./lib/notificationTypes";
|
||||
import { StdErrNotification } from "./lib/notificationTypes";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -43,7 +33,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import { ZodType } from "zod";
|
||||
import { z } from "zod";
|
||||
import "./App.css";
|
||||
import ConsoleTab from "./components/ConsoleTab";
|
||||
import HistoryAndNotifications from "./components/History";
|
||||
@@ -54,14 +44,23 @@ import RootsTab from "./components/RootsTab";
|
||||
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import ToolsTab from "./components/ToolsTab";
|
||||
import useDarkModeSync from "./lib/useDarkModeSync";
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
||||
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
||||
|
||||
const App = () => {
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"disconnected" | "connected" | "error"
|
||||
>("disconnected");
|
||||
// Handle OAuth callback route
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OAuthCallback />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
ResourceTemplate[]
|
||||
@@ -83,12 +82,15 @@ const App = () => {
|
||||
const [args, setArgs] = useState<string>(() => {
|
||||
return localStorage.getItem("lastArgs") || "";
|
||||
});
|
||||
const [url, setUrl] = useState<string>("http://localhost:3001/sse");
|
||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
||||
const [requestHistory, setRequestHistory] = useState<
|
||||
{ request: string; response?: string }[]
|
||||
>([]);
|
||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
||||
|
||||
const [sseUrl, setSseUrl] = useState<string>(() => {
|
||||
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
|
||||
});
|
||||
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
|
||||
return (
|
||||
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
||||
);
|
||||
});
|
||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||
StdErrNotification[]
|
||||
@@ -139,51 +141,43 @@ const App = () => {
|
||||
>();
|
||||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||
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);
|
||||
|
||||
useDarkModeSync();
|
||||
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
dragStartHeight.current = historyPaneHeight;
|
||||
document.body.style.userSelect = "none";
|
||||
const {
|
||||
connectionStatus,
|
||||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
makeRequest: makeConnectionRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
connect: connectMcpServer,
|
||||
} = useConnection({
|
||||
transportType,
|
||||
command,
|
||||
args,
|
||||
sseUrl,
|
||||
env,
|
||||
proxyServerUrl: PROXY_SERVER_URL,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
},
|
||||
[historyPaneHeight],
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
setHistoryPaneHeight(newHeight);
|
||||
onStdErrNotification: (notification) => {
|
||||
setStdErrNotifications((prev) => [
|
||||
...prev,
|
||||
notification as StdErrNotification,
|
||||
]);
|
||||
},
|
||||
[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]);
|
||||
onPendingRequest: (request, resolve, reject) => {
|
||||
setPendingSampleRequests((prev) => [
|
||||
...prev,
|
||||
{ id: nextRequestId.current++, request, resolve, reject },
|
||||
]);
|
||||
},
|
||||
getRoots: () => rootsRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastCommand", command);
|
||||
@@ -194,7 +188,32 @@ const App = () => {
|
||||
}, [args]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("http://localhost:3000/config")
|
||||
localStorage.setItem("lastSseUrl", sseUrl);
|
||||
}, [sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastTransportType", transportType);
|
||||
}, [transportType]);
|
||||
|
||||
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
||||
useEffect(() => {
|
||||
const serverUrl = params.get("serverUrl");
|
||||
if (serverUrl) {
|
||||
setSseUrl(serverUrl);
|
||||
setTransportType("sse");
|
||||
// Remove serverUrl from URL without reloading the page
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete("serverUrl");
|
||||
window.history.replaceState({}, "", newUrl.toString());
|
||||
// Show success toast for OAuth
|
||||
toast.success("Successfully authenticated with OAuth");
|
||||
// Connect to the server
|
||||
connectMcpServer();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${PROXY_SERVER_URL}/config`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setEnv(data.defaultEnvironment);
|
||||
@@ -214,75 +233,35 @@ const App = () => {
|
||||
rootsRef.current = roots;
|
||||
}, [roots]);
|
||||
|
||||
const pushHistory = (request: object, response?: object) => {
|
||||
setRequestHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
request: JSON.stringify(request),
|
||||
response: response !== undefined ? JSON.stringify(response) : undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = "resources";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = (tabKey: keyof typeof errors) => {
|
||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||
};
|
||||
|
||||
const makeRequest = async <T extends ZodType<object>>(
|
||||
const makeRequest = async <T extends z.ZodType>(
|
||||
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);
|
||||
|
||||
const response = await makeConnectionRequest(request, schema);
|
||||
if (tabKey !== undefined) {
|
||||
clearError(tabKey);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
} catch (e) {
|
||||
const errorString = (e as Error).message ?? String(e);
|
||||
if (tabKey === undefined) {
|
||||
toast.error(errorString);
|
||||
} else {
|
||||
if (tabKey !== undefined) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -389,79 +368,6 @@ const App = () => {
|
||||
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("http://localhost:3000/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", url);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
@@ -472,8 +378,8 @@ const App = () => {
|
||||
setCommand={setCommand}
|
||||
args={args}
|
||||
setArgs={setArgs}
|
||||
url={url}
|
||||
setUrl={setUrl}
|
||||
sseUrl={sseUrl}
|
||||
setSseUrl={setSseUrl}
|
||||
env={env}
|
||||
setEnv={setEnv}
|
||||
onConnect={connectMcpServer}
|
||||
@@ -482,17 +388,42 @@ const App = () => {
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{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">
|
||||
<TabsTrigger value="resources">
|
||||
<TabsTrigger
|
||||
value="resources"
|
||||
disabled={!serverCapabilities?.resources}
|
||||
>
|
||||
<Files className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prompts">
|
||||
<TabsTrigger
|
||||
value="prompts"
|
||||
disabled={!serverCapabilities?.prompts}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tools">
|
||||
<TabsTrigger
|
||||
value="tools"
|
||||
disabled={!serverCapabilities?.tools}
|
||||
>
|
||||
<Hammer className="w-4 h-4 mr-2" />
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
@@ -516,91 +447,123 @@ const App = () => {
|
||||
</TabsList>
|
||||
|
||||
<div className="w-full">
|
||||
<ResourcesTab
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
listResources={() => {
|
||||
clearError("resources");
|
||||
listResources();
|
||||
}}
|
||||
listResourceTemplates={() => {
|
||||
clearError("resources");
|
||||
listResourceTemplates();
|
||||
}}
|
||||
readResource={(uri) => {
|
||||
clearError("resources");
|
||||
readResource(uri);
|
||||
}}
|
||||
selectedResource={selectedResource}
|
||||
setSelectedResource={(resource) => {
|
||||
clearError("resources");
|
||||
setSelectedResource(resource);
|
||||
}}
|
||||
resourceContent={resourceContent}
|
||||
nextCursor={nextResourceCursor}
|
||||
nextTemplateCursor={nextResourceTemplateCursor}
|
||||
error={errors.resources}
|
||||
/>
|
||||
<PromptsTab
|
||||
prompts={prompts}
|
||||
listPrompts={() => {
|
||||
clearError("prompts");
|
||||
listPrompts();
|
||||
}}
|
||||
getPrompt={(name, args) => {
|
||||
clearError("prompts");
|
||||
getPrompt(name, args);
|
||||
}}
|
||||
selectedPrompt={selectedPrompt}
|
||||
setSelectedPrompt={(prompt) => {
|
||||
clearError("prompts");
|
||||
setSelectedPrompt(prompt);
|
||||
}}
|
||||
promptContent={promptContent}
|
||||
nextCursor={nextPromptCursor}
|
||||
error={errors.prompts}
|
||||
/>
|
||||
<ToolsTab
|
||||
tools={tools}
|
||||
listTools={() => {
|
||||
clearError("tools");
|
||||
listTools();
|
||||
}}
|
||||
callTool={(name, params) => {
|
||||
clearError("tools");
|
||||
callTool(name, params);
|
||||
}}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={(tool) => {
|
||||
clearError("tools");
|
||||
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}
|
||||
/>
|
||||
{!serverCapabilities?.resources &&
|
||||
!serverCapabilities?.prompts &&
|
||||
!serverCapabilities?.tools ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<p className="text-lg text-gray-500">
|
||||
The connected server does not support any MCP capabilities
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ResourcesTab
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
listResources={() => {
|
||||
clearError("resources");
|
||||
listResources();
|
||||
}}
|
||||
clearResources={() => {
|
||||
setResources([]);
|
||||
setNextResourceCursor(undefined);
|
||||
}}
|
||||
listResourceTemplates={() => {
|
||||
clearError("resources");
|
||||
listResourceTemplates();
|
||||
}}
|
||||
clearResourceTemplates={() => {
|
||||
setResourceTemplates([]);
|
||||
setNextResourceTemplateCursor(undefined);
|
||||
}}
|
||||
readResource={(uri) => {
|
||||
clearError("resources");
|
||||
readResource(uri);
|
||||
}}
|
||||
selectedResource={selectedResource}
|
||||
setSelectedResource={(resource) => {
|
||||
clearError("resources");
|
||||
setSelectedResource(resource);
|
||||
}}
|
||||
handleCompletion={handleCompletion}
|
||||
completionsSupported={completionsSupported}
|
||||
resourceContent={resourceContent}
|
||||
nextCursor={nextResourceCursor}
|
||||
nextTemplateCursor={nextResourceTemplateCursor}
|
||||
error={errors.resources}
|
||||
/>
|
||||
<PromptsTab
|
||||
prompts={prompts}
|
||||
listPrompts={() => {
|
||||
clearError("prompts");
|
||||
listPrompts();
|
||||
}}
|
||||
clearPrompts={() => {
|
||||
setPrompts([]);
|
||||
setNextPromptCursor(undefined);
|
||||
}}
|
||||
getPrompt={(name, args) => {
|
||||
clearError("prompts");
|
||||
getPrompt(name, args);
|
||||
}}
|
||||
selectedPrompt={selectedPrompt}
|
||||
setSelectedPrompt={(prompt) => {
|
||||
clearError("prompts");
|
||||
setSelectedPrompt(prompt);
|
||||
}}
|
||||
handleCompletion={handleCompletion}
|
||||
completionsSupported={completionsSupported}
|
||||
promptContent={promptContent}
|
||||
nextCursor={nextPromptCursor}
|
||||
error={errors.prompts}
|
||||
/>
|
||||
<ToolsTab
|
||||
tools={tools}
|
||||
listTools={() => {
|
||||
clearError("tools");
|
||||
listTools();
|
||||
}}
|
||||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
}}
|
||||
callTool={(name, params) => {
|
||||
clearError("tools");
|
||||
callTool(name, params);
|
||||
}}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={(tool) => {
|
||||
clearError("tools");
|
||||
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>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
||||
358
client/src/components/DynamicJsonForm.tsx
Normal file
358
client/src/components/DynamicJsonForm.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
value: JsonValue;
|
||||
onChange: (value: JsonValue) => void;
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const formatFieldLabel = (key: string): string => {
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
|
||||
.replace(/_/g, " ") // Replace underscores with spaces
|
||||
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
maxDepth = 3,
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
|
||||
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object": {
|
||||
const obj: JsonObject = {};
|
||||
if (propSchema.properties) {
|
||||
Object.entries(propSchema.properties).forEach(([key, prop]) => {
|
||||
obj[key] = generateDefaultValue(prop);
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormFields = (
|
||||
propSchema: JsonSchemaType,
|
||||
currentValue: JsonValue,
|
||||
path: string[] = [],
|
||||
depth: number = 0,
|
||||
) => {
|
||||
if (
|
||||
depth >= maxDepth &&
|
||||
(propSchema.type === "object" || propSchema.type === "array")
|
||||
) {
|
||||
// Render as JSON editor when max depth is reached
|
||||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(
|
||||
currentValue ?? generateDefaultValue(propSchema),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
handleFieldChange(path, parsed);
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
type={propSchema.type === "string" ? "text" : "number"}
|
||||
value={(currentValue as string | number) ?? ""}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
path,
|
||||
propSchema.type === "string"
|
||||
? e.target.value
|
||||
: Number(e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder={propSchema.description}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
);
|
||||
case "object":
|
||||
if (!propSchema.properties) return null;
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
{renderFormFields(
|
||||
prop,
|
||||
(currentValue as JsonObject)?.[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "array": {
|
||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">{propSchema.description}</p>
|
||||
)}
|
||||
|
||||
{propSchema.items?.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {propSchema.items.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{renderFormFields(
|
||||
propSchema.items as JsonSchemaType,
|
||||
item,
|
||||
[...path, index.toString()],
|
||||
depth + 1,
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray.splice(index, 1);
|
||||
handleFieldChange(path, newArray);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleFieldChange(path, [
|
||||
...arrayValue,
|
||||
generateDefaultValue(propSchema.items as JsonSchemaType),
|
||||
]);
|
||||
}}
|
||||
title={
|
||||
propSchema.items?.description
|
||||
? `Add new ${propSchema.items.description}`
|
||||
: "Add new item"
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (path: string[], fieldValue: JsonValue) => {
|
||||
if (path.length === 0) {
|
||||
onChange(fieldValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateArray = (
|
||||
array: JsonValue[],
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue[] => {
|
||||
const [index, ...restPath] = path;
|
||||
const arrayIndex = Number(index);
|
||||
|
||||
// Validate array index
|
||||
if (isNaN(arrayIndex)) {
|
||||
console.error(`Invalid array index: ${index}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
// Check array bounds
|
||||
if (arrayIndex < 0) {
|
||||
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
||||
return array;
|
||||
}
|
||||
|
||||
const newArray = [...array];
|
||||
|
||||
if (restPath.length === 0) {
|
||||
newArray[arrayIndex] = value;
|
||||
} else {
|
||||
// Ensure index position exists
|
||||
if (arrayIndex >= array.length) {
|
||||
console.warn(`Extending array to index ${arrayIndex}`);
|
||||
newArray.length = arrayIndex + 1;
|
||||
newArray.fill(null, array.length, arrayIndex);
|
||||
}
|
||||
newArray[arrayIndex] = updateValue(
|
||||
newArray[arrayIndex],
|
||||
restPath,
|
||||
value,
|
||||
);
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const updateObject = (
|
||||
obj: JsonObject,
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonObject => {
|
||||
const [key, ...restPath] = path;
|
||||
|
||||
// Validate object key
|
||||
if (typeof key !== "string") {
|
||||
console.error(`Invalid object key: ${key}`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
const newObj = { ...obj };
|
||||
|
||||
if (restPath.length === 0) {
|
||||
newObj[key] = value;
|
||||
} else {
|
||||
// Ensure key exists
|
||||
if (!(key in newObj)) {
|
||||
console.warn(`Creating new key in object: ${key}`);
|
||||
newObj[key] = {};
|
||||
}
|
||||
newObj[key] = updateValue(newObj[key], restPath, value);
|
||||
}
|
||||
return newObj;
|
||||
};
|
||||
|
||||
const updateValue = (
|
||||
current: JsonValue,
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue => {
|
||||
if (path.length === 0) return value;
|
||||
|
||||
try {
|
||||
if (!current) {
|
||||
current = !isNaN(Number(path[0])) ? [] : {};
|
||||
}
|
||||
|
||||
// Type checking
|
||||
if (Array.isArray(current)) {
|
||||
return updateArray(current, path, value);
|
||||
} else if (typeof current === "object" && current !== null) {
|
||||
return updateObject(current, path, value);
|
||||
} else {
|
||||
console.error(
|
||||
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
||||
current,
|
||||
);
|
||||
return current;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating value at path ${path.join(".")}:`, error);
|
||||
return current;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const newValue = updateValue(value, path, fieldValue);
|
||||
onChange(newValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to update form value:", error);
|
||||
// Keep the original value unchanged
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsJsonMode(!isJsonMode)}
|
||||
>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isJsonMode ? (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
onChange(JSON.parse(newValue));
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
) : (
|
||||
renderFormFields(schema, value)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicJsonForm;
|
||||
59
client/src/components/JsonEditor.tsx
Normal file
59
client/src/components/JsonEditor.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Editor from "react-simple-code-editor";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/themes/prism.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
||||
const formatJson = (json: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
} catch {
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(formatJson(value))}
|
||||
>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`border rounded-md ${
|
||||
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
highlight={(code) =>
|
||||
Prism.highlight(code, Prism.languages.json, "json")
|
||||
}
|
||||
padding={10}
|
||||
style={{
|
||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||
fontSize: 14,
|
||||
backgroundColor: "transparent",
|
||||
minHeight: "100px",
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
|
||||
type ListPaneProps<T> = {
|
||||
items: T[];
|
||||
listItems: () => void;
|
||||
clearItems: () => void;
|
||||
setSelectedItem: (item: T) => void;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
title: string;
|
||||
@@ -13,6 +14,7 @@ type ListPaneProps<T> = {
|
||||
const ListPane = <T extends object>({
|
||||
items,
|
||||
listItems,
|
||||
clearItems,
|
||||
setSelectedItem,
|
||||
renderItem,
|
||||
title,
|
||||
@@ -32,6 +34,14 @@ const ListPane = <T extends object>({
|
||||
>
|
||||
{buttonText}
|
||||
</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">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
|
||||
56
client/src/components/OAuthCallback.tsx
Normal file
56
client/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { authProvider } from "../lib/auth";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
|
||||
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 result = await auth(authProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: code,
|
||||
});
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new Error(
|
||||
`Expected to be authorized after providing auth code, got: ${result}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
ListPromptsResult,
|
||||
PromptReference,
|
||||
ResourceReference,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||
|
||||
export type Prompt = {
|
||||
name: string;
|
||||
@@ -22,26 +27,53 @@ export type Prompt = {
|
||||
const PromptsTab = ({
|
||||
prompts,
|
||||
listPrompts,
|
||||
clearPrompts,
|
||||
getPrompt,
|
||||
selectedPrompt,
|
||||
setSelectedPrompt,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
promptContent,
|
||||
nextCursor,
|
||||
error,
|
||||
}: {
|
||||
prompts: Prompt[];
|
||||
listPrompts: () => void;
|
||||
clearPrompts: () => void;
|
||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||
selectedPrompt: Prompt | null;
|
||||
setSelectedPrompt: (prompt: Prompt) => void;
|
||||
handleCompletion: (
|
||||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
promptContent: string;
|
||||
nextCursor: ListPromptsResult["nextCursor"];
|
||||
error: string | null;
|
||||
}) => {
|
||||
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
|
||||
const { completions, clearCompletions, requestCompletions } =
|
||||
useCompletionState(handleCompletion, completionsSupported);
|
||||
|
||||
const handleInputChange = (argName: string, value: string) => {
|
||||
useEffect(() => {
|
||||
clearCompletions();
|
||||
}, [clearCompletions, selectedPrompt]);
|
||||
|
||||
const handleInputChange = async (argName: string, value: string) => {
|
||||
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
|
||||
|
||||
if (selectedPrompt) {
|
||||
requestCompletions(
|
||||
{
|
||||
type: "ref/prompt",
|
||||
name: selectedPrompt.name,
|
||||
},
|
||||
argName,
|
||||
value,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetPrompt = () => {
|
||||
@@ -55,6 +87,7 @@ const PromptsTab = ({
|
||||
<ListPane
|
||||
items={prompts}
|
||||
listItems={listPrompts}
|
||||
clearItems={clearPrompts}
|
||||
setSelectedItem={(prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setPromptArgs({});
|
||||
@@ -93,14 +126,17 @@ const PromptsTab = ({
|
||||
{selectedPrompt.arguments?.map((arg) => (
|
||||
<div key={arg.name}>
|
||||
<Label htmlFor={arg.name}>{arg.name}</Label>
|
||||
<Input
|
||||
<Combobox
|
||||
id={arg.name}
|
||||
placeholder={`Enter ${arg.name}`}
|
||||
value={promptArgs[arg.name] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(arg.name, e.target.value)
|
||||
onChange={(value) => handleInputChange(arg.name, value)}
|
||||
onInputChange={(value) =>
|
||||
handleInputChange(arg.name, value)
|
||||
}
|
||||
options={completions[arg.name] || []}
|
||||
/>
|
||||
|
||||
{arg.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{arg.description}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
ListResourcesResult,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
ListResourceTemplatesResult,
|
||||
ResourceReference,
|
||||
PromptReference,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
|
||||
import ListPane from "./ListPane";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||
|
||||
const ResourcesTab = ({
|
||||
resources,
|
||||
resourceTemplates,
|
||||
listResources,
|
||||
clearResources,
|
||||
listResourceTemplates,
|
||||
clearResourceTemplates,
|
||||
readResource,
|
||||
selectedResource,
|
||||
setSelectedResource,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
resourceContent,
|
||||
nextCursor,
|
||||
nextTemplateCursor,
|
||||
@@ -28,10 +36,18 @@ const ResourcesTab = ({
|
||||
resources: Resource[];
|
||||
resourceTemplates: ResourceTemplate[];
|
||||
listResources: () => void;
|
||||
clearResources: () => void;
|
||||
listResourceTemplates: () => void;
|
||||
clearResourceTemplates: () => void;
|
||||
readResource: (uri: string) => void;
|
||||
selectedResource: Resource | null;
|
||||
setSelectedResource: (resource: Resource | null) => void;
|
||||
handleCompletion: (
|
||||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
resourceContent: string;
|
||||
nextCursor: ListResourcesResult["nextCursor"];
|
||||
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
|
||||
@@ -43,6 +59,13 @@ const ResourcesTab = ({
|
||||
{},
|
||||
);
|
||||
|
||||
const { completions, clearCompletions, requestCompletions } =
|
||||
useCompletionState(handleCompletion, completionsSupported);
|
||||
|
||||
useEffect(() => {
|
||||
clearCompletions();
|
||||
}, [clearCompletions]);
|
||||
|
||||
const fillTemplate = (
|
||||
template: string,
|
||||
values: Record<string, string>,
|
||||
@@ -53,6 +76,21 @@ const ResourcesTab = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleTemplateValueChange = async (key: string, value: string) => {
|
||||
setTemplateValues((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
if (selectedTemplate?.uriTemplate) {
|
||||
requestCompletions(
|
||||
{
|
||||
type: "ref/resource",
|
||||
uri: selectedTemplate.uriTemplate,
|
||||
},
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadTemplateResource = () => {
|
||||
if (selectedTemplate) {
|
||||
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
|
||||
@@ -68,6 +106,7 @@ const ResourcesTab = ({
|
||||
<ListPane
|
||||
items={resources}
|
||||
listItems={listResources}
|
||||
clearItems={clearResources}
|
||||
setSelectedItem={(resource) => {
|
||||
setSelectedResource(resource);
|
||||
readResource(resource.uri);
|
||||
@@ -90,6 +129,7 @@ const ResourcesTab = ({
|
||||
<ListPane
|
||||
items={resourceTemplates}
|
||||
listItems={listResourceTemplates}
|
||||
clearItems={clearResourceTemplates}
|
||||
setSelectedItem={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSelectedResource(null);
|
||||
@@ -156,22 +196,18 @@ const ResourcesTab = ({
|
||||
const key = param.slice(1, -1);
|
||||
return (
|
||||
<div key={key}>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{key}
|
||||
</label>
|
||||
<Input
|
||||
<Label htmlFor={key}>{key}</Label>
|
||||
<Combobox
|
||||
id={key}
|
||||
placeholder={`Enter ${key}`}
|
||||
value={templateValues[key] || ""}
|
||||
onChange={(e) =>
|
||||
setTemplateValues({
|
||||
...templateValues,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
onChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
className="mt-1"
|
||||
onInputChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
options={completions[key] || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Play, ChevronDown, ChevronRight, Settings } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Bug,
|
||||
Github,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -12,6 +20,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
||||
|
||||
import useTheme from "../lib/useTheme";
|
||||
import { version } from "../../../package.json";
|
||||
|
||||
interface SidebarProps {
|
||||
connectionStatus: "disconnected" | "connected" | "error";
|
||||
transportType: "stdio" | "sse";
|
||||
@@ -20,8 +31,8 @@ interface SidebarProps {
|
||||
setCommand: (command: string) => void;
|
||||
args: string;
|
||||
setArgs: (args: string) => void;
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
sseUrl: string;
|
||||
setSseUrl: (url: string) => void;
|
||||
env: Record<string, string>;
|
||||
setEnv: (env: Record<string, string>) => void;
|
||||
onConnect: () => void;
|
||||
@@ -36,20 +47,25 @@ const Sidebar = ({
|
||||
setCommand,
|
||||
args,
|
||||
setArgs,
|
||||
url,
|
||||
setUrl,
|
||||
sseUrl,
|
||||
setSseUrl,
|
||||
env,
|
||||
setEnv,
|
||||
onConnect,
|
||||
stdErrNotifications,
|
||||
}: SidebarProps) => {
|
||||
const [theme, setTheme] = useTheme();
|
||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||
<div className="flex items-center p-4 border-b border-gray-200">
|
||||
<Settings className="w-6 h-6 text-gray-500" />
|
||||
<h1 className="ml-2 text-lg font-semibold">MCP Inspector</h1>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<h1 className="ml-2 text-lg font-semibold">
|
||||
MCP Inspector v{version}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
@@ -71,6 +87,7 @@ const Sidebar = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transportType === "stdio" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -79,6 +96,7 @@ const Sidebar = ({
|
||||
placeholder="Command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -87,6 +105,7 @@ const Sidebar = ({
|
||||
placeholder="Arguments (space-separated)"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -95,8 +114,9 @@ const Sidebar = ({
|
||||
<label className="text-sm font-medium">URL</label>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
value={sseUrl}
|
||||
onChange={(e) => setSseUrl(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -117,19 +137,44 @@ const Sidebar = ({
|
||||
{showEnvVars && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(env).map(([key, value], idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr,auto] gap-2">
|
||||
<div className="space-y-1">
|
||||
<div key={idx} className="space-y-2 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newKey = e.target.value;
|
||||
const newEnv = { ...env };
|
||||
delete newEnv[key];
|
||||
newEnv[e.target.value] = value;
|
||||
newEnv[newKey] = value;
|
||||
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
|
||||
type={shownEnvVars.has(key) ? "text" : "password"}
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
@@ -137,25 +182,47 @@ const Sidebar = ({
|
||||
newEnv[key] = e.target.value;
|
||||
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>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...rest } = env;
|
||||
setEnv(rest);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
onClick={() => {
|
||||
const key = "";
|
||||
const newEnv = { ...env };
|
||||
newEnv[""] = "";
|
||||
newEnv[key] = "";
|
||||
setEnv(newEnv);
|
||||
}}
|
||||
>
|
||||
@@ -212,6 +279,58 @@ const Sidebar = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={(value: string) =>
|
||||
setTheme(value as "system" | "light" | "dark")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]" id="theme-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
||||
import {
|
||||
CallToolResult,
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
CallToolResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AlertCircle, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
|
||||
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
@@ -18,6 +20,7 @@ import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
listTools,
|
||||
clearTools,
|
||||
callTool,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
@@ -27,20 +30,44 @@ const ToolsTab = ({
|
||||
}: {
|
||||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
clearTools: () => void;
|
||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
||||
selectedTool: Tool | null;
|
||||
setSelectedTool: (tool: Tool) => void;
|
||||
setSelectedTool: (tool: Tool | null) => void;
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
nextCursor: ListToolsResult["nextCursor"];
|
||||
error: string | null;
|
||||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
useEffect(() => {
|
||||
setParams({});
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const structuredResult = toolResult as CallToolResult;
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
if (!parsedResult.success) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||
<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, null, 2)}
|
||||
</pre>
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<pre
|
||||
key={idx}
|
||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
|
||||
>
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</pre>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
return (
|
||||
@@ -51,7 +78,7 @@ const ToolsTab = ({
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<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">
|
||||
{item.text}
|
||||
</pre>
|
||||
)}
|
||||
@@ -62,11 +89,20 @@ const ToolsTab = ({
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" && (
|
||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
|
||||
{JSON.stringify(item.resource, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
<audio
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
@@ -75,7 +111,7 @@ const ToolsTab = ({
|
||||
return (
|
||||
<>
|
||||
<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)}
|
||||
</pre>
|
||||
</>
|
||||
@@ -88,6 +124,10 @@ const ToolsTab = ({
|
||||
<ListPane
|
||||
items={tools}
|
||||
listItems={listTools}
|
||||
clearItems={() => {
|
||||
clearTools();
|
||||
setSelectedTool(null);
|
||||
}}
|
||||
setSelectedItem={setSelectedTool}
|
||||
renderItem={(tool) => (
|
||||
<>
|
||||
@@ -121,22 +161,42 @@ const ToolsTab = ({
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => (
|
||||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{key}
|
||||
</Label>
|
||||
{
|
||||
/* @ts-expect-error value type is currently unknown */
|
||||
value.type === "string" ? (
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{key}
|
||||
</Label>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
name={key}
|
||||
checked={!!params[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{prop.description || "Toggle this option"}
|
||||
</label>
|
||||
</div>
|
||||
) : prop.type === "string" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
name={key}
|
||||
// @ts-expect-error value type is currently unknown
|
||||
placeholder={value.description}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
@@ -145,30 +205,45 @@ const ToolsTab = ({
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : prop.type === "object" || prop.type === "array" ? (
|
||||
<div className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={(params[key] as JsonValue) ?? {}}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
// @ts-expect-error value type is currently unknown
|
||||
type={value.type === "number" ? "number" : "text"}
|
||||
type={prop.type === "number" ? "number" : "text"}
|
||||
id={key}
|
||||
name={key}
|
||||
// @ts-expect-error value type is currently unknown
|
||||
placeholder={value.description}
|
||||
placeholder={prop.description}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
// @ts-expect-error value type is currently unknown
|
||||
value.type === "number"
|
||||
prop.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
|
||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
97
client/src/components/ui/combobox.tsx
Normal file
97
client/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface ComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
onInputChange,
|
||||
options = [],
|
||||
placeholder = "Select...",
|
||||
emptyMessage = "No results found.",
|
||||
id,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(option: string) => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(value: string) => {
|
||||
onInputChange(value);
|
||||
},
|
||||
[onInputChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{value || placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false} id={id}>
|
||||
<CommandInput
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onValueChange={handleInputChange}
|
||||
/>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => handleSelect(option)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
150
client/src/components/ui/command.tsx
Normal file
150
client/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
121
client/src/components/ui/dialog.tsx
Normal file
121
client/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
31
client/src/components/ui/popover.tsx
Normal file
31
client/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -57,6 +57,10 @@ button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
button[role="checkbox"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
|
||||
73
client/src/lib/auth.ts
Normal file
73
client/src/lib/auth.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
OAuthClientInformationSchema,
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS } from "./constants";
|
||||
|
||||
class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
client_name: "MCP Inspector",
|
||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||
};
|
||||
}
|
||||
|
||||
async clientInformation() {
|
||||
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||
sessionStorage.setItem(
|
||||
SESSION_KEYS.CLIENT_INFORMATION,
|
||||
JSON.stringify(clientInformation),
|
||||
);
|
||||
}
|
||||
|
||||
async tokens() {
|
||||
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
|
||||
if (!tokens) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens) {
|
||||
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL) {
|
||||
window.location.href = authorizationUrl.href;
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string) {
|
||||
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
||||
}
|
||||
|
||||
codeVerifier() {
|
||||
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
||||
if (!verifier) {
|
||||
throw new Error("No code verifier saved for session");
|
||||
}
|
||||
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
|
||||
export const authProvider = new InspectorOAuthClientProvider();
|
||||
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",
|
||||
TOKENS: "mcp_tokens",
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
} as const;
|
||||
128
client/src/lib/hooks/useCompletionState.ts
Normal file
128
client/src/lib/hooks/useCompletionState.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
ResourceReference,
|
||||
PromptReference,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
interface CompletionState {
|
||||
completions: Record<string, string[]>;
|
||||
loading: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return function (...args: Parameters<T>) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function useCompletionState(
|
||||
handleCompletion: (
|
||||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string[]>,
|
||||
completionsSupported: boolean = true,
|
||||
debounceMs: number = 300,
|
||||
) {
|
||||
const [state, setState] = useState<CompletionState>({
|
||||
completions: {},
|
||||
loading: {},
|
||||
});
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return cleanup;
|
||||
}, [cleanup]);
|
||||
|
||||
const clearCompletions = useCallback(() => {
|
||||
cleanup();
|
||||
setState({
|
||||
completions: {},
|
||||
loading: {},
|
||||
});
|
||||
}, [cleanup]);
|
||||
|
||||
const requestCompletions = useCallback(
|
||||
debounce(
|
||||
async (
|
||||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
) => {
|
||||
if (!completionsSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: { ...prev.loading, [argName]: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const values = await handleCompletion(
|
||||
ref,
|
||||
argName,
|
||||
value,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
completions: { ...prev.completions, [argName]: values },
|
||||
loading: { ...prev.loading, [argName]: false },
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: { ...prev.loading, [argName]: false },
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
debounceMs,
|
||||
),
|
||||
[handleCompletion, completionsSupported, cleanup, debounceMs],
|
||||
);
|
||||
|
||||
// Clear completions when support status changes
|
||||
useEffect(() => {
|
||||
if (!completionsSupported) {
|
||||
clearCompletions();
|
||||
}
|
||||
}, [completionsSupported, clearCompletions]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
clearCompletions,
|
||||
requestCompletions,
|
||||
completionsSupported,
|
||||
};
|
||||
}
|
||||
310
client/src/lib/hooks/useConnection.ts
Normal file
310
client/src/lib/hooks/useConnection.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
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,
|
||||
PromptReference,
|
||||
ResourceReference,
|
||||
McpError,
|
||||
CompleteResultSchema,
|
||||
ErrorCode,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { z } from "zod";
|
||||
import { SESSION_KEYS } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { authProvider } from "../auth";
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
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 [completionsSupported, setCompletionsSupported] = useState(true);
|
||||
|
||||
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,
|
||||
options?: RequestOptions,
|
||||
): Promise<z.output<T>> => {
|
||||
if (!mcpClient) {
|
||||
throw new Error("MCP client not connected");
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort("Request timed out");
|
||||
}, options?.timeout ?? requestTimeout);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await mcpClient.request(request, schema, {
|
||||
signal: options?.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) {
|
||||
if (!options?.suppressToast) {
|
||||
const errorString = (e as Error).message ?? String(e);
|
||||
toast.error(errorString);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompletion = async (
|
||||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
if (!mcpClient || !completionsSupported) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const request: ClientRequest = {
|
||||
method: "completion/complete",
|
||||
params: {
|
||||
argument: {
|
||||
name: argName,
|
||||
value,
|
||||
},
|
||||
ref,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await makeRequest(request, CompleteResultSchema, {
|
||||
signal,
|
||||
suppressToast: true,
|
||||
});
|
||||
return response?.completion.values || [];
|
||||
} catch (e: unknown) {
|
||||
// Disable completions silently if the server doesn't support them.
|
||||
// See https://github.com/modelcontextprotocol/specification/discussions/122
|
||||
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
|
||||
setCompletionsSupported(false);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unexpected errors - show toast and rethrow
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const sendNotification = async (notification: ClientNotification) => {
|
||||
if (!mcpClient) {
|
||||
const error = new Error("MCP client not connected");
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await mcpClient.notification(notification);
|
||||
// Log successful notifications
|
||||
pushHistory(notification);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof McpError) {
|
||||
// Log MCP protocol errors
|
||||
pushHistory(notification, { error: e.message });
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
||||
|
||||
const result = await auth(authProvider, { serverUrl: sseUrl });
|
||||
return result === "AUTHORIZED";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||
// proxying through the inspector server first.
|
||||
const headers: HeadersInit = {};
|
||||
const tokens = await authProvider.tokens();
|
||||
if (tokens) {
|
||||
headers["Authorization"] = `Bearer ${tokens.access_token}`;
|
||||
}
|
||||
|
||||
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);
|
||||
setCompletionsSupported(true); // Reset completions support on new connection
|
||||
|
||||
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,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
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,29 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Listen for changes to the user's preferred color scheme
|
||||
const useDarkModeSync = () => {
|
||||
useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
};
|
||||
|
||||
if (darkModeMediaQuery.matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useDarkModeSync;
|
||||
51
client/src/lib/useTheme.ts
Normal file
51
client/src/lib/useTheme.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
const useTheme = (): [Theme, (mode: Theme) => void] => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||
return savedTheme || "system";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
||||
if (theme === "system") {
|
||||
updateDocumentTheme(e.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
|
||||
const updateDocumentTheme = (newTheme: "light" | "dark") => {
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
};
|
||||
|
||||
// Set initial theme based on current mode
|
||||
if (theme === "system") {
|
||||
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
|
||||
} else {
|
||||
updateDocumentTheme(theme);
|
||||
}
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return [
|
||||
theme,
|
||||
useCallback((newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
if (newTheme !== "system") {
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
}
|
||||
}, []),
|
||||
];
|
||||
};
|
||||
|
||||
export default useTheme;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import animate from "tailwindcss-animate";
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
@@ -53,5 +54,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [animate],
|
||||
};
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
BIN
mcp-inspector.png
Normal file
BIN
mcp-inspector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
3778
package-lock.json
generated
3778
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.1",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -22,23 +22,28 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
||||
"build-server": "cd server && npm run build",
|
||||
"build-client": "cd client && npm run build",
|
||||
"build": "npm run build-server && npm run build-client",
|
||||
"start-server": "cd server && npm run start",
|
||||
"start-client": "cd client && npm run preview",
|
||||
"start": "./bin/cli.js",
|
||||
"start": "node ./bin/cli.js",
|
||||
"prepare": "npm run build",
|
||||
"prettier-fix": "prettier --write .",
|
||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-client": "0.2.0",
|
||||
"@modelcontextprotocol/inspector-server": "0.2.0",
|
||||
"concurrently": "^9.0.1"
|
||||
"@modelcontextprotocol/inspector-client": "0.4.1",
|
||||
"@modelcontextprotocol/inspector-server": "0.4.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"shell-quote": "^1.8.2",
|
||||
"spawn-rx": "^5.1.2",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.3.3",
|
||||
"@types/node": "^22.7.5"
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"prettier": "3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.1",
|
||||
"description": "Server-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -16,20 +16,19 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node build/index.js",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts"
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/eventsource": "^1.1.15",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"eventsource": "^2.0.2",
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import cors from "cors";
|
||||
import EventSource from "eventsource";
|
||||
import { parseArgs } from "node:util";
|
||||
import { parse as shellParseArgs } from "shell-quote";
|
||||
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
StdioClientTransport,
|
||||
getDefaultEnvironment,
|
||||
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from "express";
|
||||
import { findActualExecutable } from "spawn-rx";
|
||||
import mcpProxy from "./mcpProxy.js";
|
||||
|
||||
// Polyfill EventSource for an SSE client in Node.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).EventSource = EventSource;
|
||||
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||
|
||||
const defaultEnvironment = {
|
||||
...getDefaultEnvironment(),
|
||||
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
@@ -30,32 +37,57 @@ app.use(cors());
|
||||
|
||||
let webAppTransports: SSEServerTransport[] = [];
|
||||
|
||||
const createTransport = async (query: express.Request["query"]) => {
|
||||
const createTransport = async (req: express.Request) => {
|
||||
const query = req.query;
|
||||
console.log("Query parameters:", query);
|
||||
|
||||
const transportType = query.transportType as string;
|
||||
|
||||
if (transportType === "stdio") {
|
||||
const command = query.command as string;
|
||||
const args = (query.args as string).split(/\s+/);
|
||||
const env = query.env ? JSON.parse(query.env as string) : undefined;
|
||||
console.log(
|
||||
`Stdio transport: command=${command}, args=${args}, env=${JSON.stringify(env)}`,
|
||||
);
|
||||
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
|
||||
|
||||
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||
|
||||
console.log(`Stdio transport: command=${cmd}, args=${args}`);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
command: cmd,
|
||||
args,
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
console.log("Spawned stdio transport");
|
||||
return transport;
|
||||
} else if (transportType === "sse") {
|
||||
const url = query.url as string;
|
||||
console.log(`SSE transport: url=${url}`);
|
||||
const transport = new SSEClientTransport(new URL(url));
|
||||
const headers: HeadersInit = {};
|
||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||
if (req.headers[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = req.headers[key];
|
||||
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||
}
|
||||
|
||||
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
|
||||
|
||||
const transport = new SSEClientTransport(new URL(url), {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
console.log("Connected to SSE transport");
|
||||
return transport;
|
||||
} else {
|
||||
@@ -68,7 +100,21 @@ app.get("/sse", async (req, res) => {
|
||||
try {
|
||||
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");
|
||||
|
||||
@@ -95,10 +141,8 @@ app.get("/sse", async (req, res) => {
|
||||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: backingServerTransport,
|
||||
onerror: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Set up MCP proxy");
|
||||
} catch (error) {
|
||||
console.error("Error in /sse route:", error);
|
||||
@@ -125,7 +169,6 @@ app.post("/message", async (req, res) => {
|
||||
|
||||
app.get("/config", (req, res) => {
|
||||
try {
|
||||
const defaultEnvironment = getDefaultEnvironment();
|
||||
res.json({
|
||||
defaultEnvironment,
|
||||
defaultCommand: values.env,
|
||||
@@ -138,4 +181,16 @@ app.get("/config", (req, res) => {
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
function onClientError(error: Error) {
|
||||
console.error("Error from inspector client:", error);
|
||||
}
|
||||
|
||||
function onServerError(error: Error) {
|
||||
console.error("Error from MCP server:", error);
|
||||
}
|
||||
|
||||
export default function mcpProxy({
|
||||
transportToClient,
|
||||
transportToServer,
|
||||
onerror,
|
||||
}: {
|
||||
transportToClient: Transport;
|
||||
transportToServer: Transport;
|
||||
onerror: (error: Error) => void;
|
||||
}) {
|
||||
let transportToClientClosed = false;
|
||||
let transportToServerClosed = false;
|
||||
|
||||
transportToClient.onmessage = (message) => {
|
||||
transportToServer.send(message).catch(onerror);
|
||||
transportToServer.send(message).catch(onServerError);
|
||||
};
|
||||
|
||||
transportToServer.onmessage = (message) => {
|
||||
transportToClient.send(message).catch(onerror);
|
||||
transportToClient.send(message).catch(onClientError);
|
||||
};
|
||||
|
||||
transportToClient.onclose = () => {
|
||||
@@ -26,7 +32,7 @@ export default function mcpProxy({
|
||||
}
|
||||
|
||||
transportToClientClosed = true;
|
||||
transportToServer.close().catch(onerror);
|
||||
transportToServer.close().catch(onServerError);
|
||||
};
|
||||
|
||||
transportToServer.onclose = () => {
|
||||
@@ -34,10 +40,9 @@ export default function mcpProxy({
|
||||
return;
|
||||
}
|
||||
transportToServerClosed = true;
|
||||
|
||||
transportToClient.close().catch(onerror);
|
||||
transportToClient.close().catch(onClientError);
|
||||
};
|
||||
|
||||
transportToClient.onerror = onerror;
|
||||
transportToServer.onerror = onerror;
|
||||
transportToClient.onerror = onClientError;
|
||||
transportToServer.onerror = onServerError;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user