Compare commits
227 Commits
ashwin/tes
...
0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c031831e71 | ||
|
|
16b38071e7 | ||
|
|
731cee8511 | ||
|
|
30e7a4d7b7 | ||
|
|
f2f209dbd3 | ||
|
|
f8b7b88a25 | ||
|
|
2890e036ed | ||
|
|
008204fda5 | ||
|
|
af44efb236 | ||
|
|
51f3135c76 | ||
|
|
3488bdb613 | ||
|
|
043f6040c6 | ||
|
|
74d0fcf5a3 | ||
|
|
de8795106c | ||
|
|
c726c53b00 | ||
|
|
20db043b40 | ||
|
|
dfc9cf7629 | ||
|
|
4fdbcee706 | ||
|
|
029e482e05 | ||
|
|
c463dc58c2 | ||
|
|
a85d5e7050 | ||
|
|
7ddba51b36 | ||
|
|
50131c6960 | ||
|
|
28978ea24f | ||
|
|
cae7c76358 | ||
|
|
50a65d0c7a | ||
|
|
e1b015e40d | ||
|
|
7c4ed6abca | ||
|
|
a3740c4798 | ||
|
|
d8b5bdb613 | ||
|
|
5104952239 | ||
|
|
67722aea71 | ||
|
|
cedf02d152 | ||
|
|
f01f02d5be | ||
|
|
56932e8a93 | ||
|
|
aeaf32fa45 | ||
|
|
090b7efdea | ||
|
|
d70e6dc0e8 | ||
|
|
1f214deeab | ||
|
|
c77252900a | ||
|
|
498c02b0f1 | ||
|
|
60c4645eaf | ||
|
|
fe8b1ee88b | ||
|
|
04a90e8d89 | ||
|
|
e5ee00bf89 | ||
|
|
397a0f651f | ||
|
|
0281e5f821 | ||
|
|
f56961ac62 | ||
|
|
15bbb7502b | ||
|
|
7caf6f8ba8 | ||
|
|
dbd616905c | ||
|
|
1ff410ca3d | ||
|
|
35a0f4611a | ||
|
|
952bee2605 | ||
|
|
a669272fda | ||
|
|
747c0154c5 | ||
|
|
0870a81990 | ||
|
|
ca18faa7c3 | ||
|
|
014730fb2f | ||
|
|
9c690e004b | ||
|
|
b9b116a5f2 | ||
|
|
4efe7d7899 | ||
|
|
027eb02422 | ||
|
|
b116264f90 | ||
|
|
290d5ab49e | ||
|
|
826ce37d2c | ||
|
|
7a56a7200c | ||
|
|
1eba99c531 | ||
|
|
00836dbf9e | ||
|
|
dd02b69036 | ||
|
|
f9b105c0ef | ||
|
|
1ae77e9ef8 | ||
|
|
13ae2b5659 | ||
|
|
06773bb6dd | ||
|
|
b01e386659 | ||
|
|
e7f55f083f | ||
|
|
36aa7316ea | ||
|
|
0e50b68f96 | ||
|
|
a1eb343b79 | ||
|
|
82bbe58a46 | ||
|
|
44982e6c97 | ||
|
|
6ec82e21b1 | ||
|
|
abd4877dae | ||
|
|
d1f5b3b933 | ||
|
|
720480cbbb | ||
|
|
8ac7ef0985 | ||
|
|
238c22830b | ||
|
|
426fb87640 | ||
|
|
90ce628040 | ||
|
|
d4a64fb5d8 | ||
|
|
ede1ea0faa | ||
|
|
0747479694 | ||
|
|
db1b5cbc45 | ||
|
|
0b105b29c1 | ||
|
|
0e29e2c1cf | ||
|
|
989efb2204 | ||
|
|
592dacad39 | ||
|
|
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 | ||
|
|
243ee1a6b5 | ||
|
|
c78b0fbed6 | ||
|
|
0fa56e14d9 | ||
|
|
35effc4d16 | ||
|
|
14802b8043 | ||
|
|
068d21387a | ||
|
|
66b1b73448 |
8
.github/workflows/main.yml
vendored
8
.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
|
||||
@@ -22,6 +25,11 @@ jobs:
|
||||
# Working around https://github.com/npm/cli/issues/4828
|
||||
# - run: npm ci
|
||||
- run: npm install --no-package-lock
|
||||
|
||||
- name: Run client tests
|
||||
working-directory: ./client
|
||||
run: npm test
|
||||
|
||||
- run: npm run build
|
||||
|
||||
publish:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,6 @@
|
||||
node_modules
|
||||
server/build
|
||||
client/dist
|
||||
client/coverage
|
||||
client/tsconfig.app.tsbuildinfo
|
||||
client/tsconfig.node.tsbuildinfo
|
||||
.vscode
|
||||
|
||||
@@ -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
|
||||
31
README.md
31
README.md
@@ -11,23 +11,37 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||
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 build/index.js
|
||||
npx @modelcontextprotocol/inspector node build/index.js
|
||||
```
|
||||
|
||||
You can also pass arguments along which will get passed as arguments to your MCP server:
|
||||
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
|
||||
|
||||
```
|
||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
||||
```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
|
||||
```
|
||||
|
||||
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 build/index.js
|
||||
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).
|
||||
|
||||
### Authentication
|
||||
|
||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
||||
|
||||
### From this repository
|
||||
|
||||
If you're working on the inspector itself:
|
||||
@@ -38,6 +52,13 @@ Development mode:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> **Note for Windows users:**
|
||||
> On Windows, use the following command instead:
|
||||
>
|
||||
> ```bash
|
||||
> npm run dev:windows
|
||||
> ```
|
||||
|
||||
Production mode:
|
||||
|
||||
```bash
|
||||
|
||||
40
bin/cli.js
40
bin/cli.js
@@ -11,8 +11,38 @@ function delay(ms) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get command line arguments
|
||||
const [, , command, ...mcpServerArgs] = process.argv;
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const envVars = {};
|
||||
const mcpServerArgs = [];
|
||||
let command = null;
|
||||
let parsingFlags = true;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (parsingFlags && arg === "--") {
|
||||
parsingFlags = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||
const envVar = args[++i];
|
||||
const equalsIndex = envVar.indexOf("=");
|
||||
|
||||
if (equalsIndex !== -1) {
|
||||
const key = envVar.substring(0, equalsIndex);
|
||||
const value = envVar.substring(equalsIndex + 1);
|
||||
envVars[key] = value;
|
||||
} else {
|
||||
envVars[envVar] = "";
|
||||
}
|
||||
} else if (!command) {
|
||||
command = arg;
|
||||
} else {
|
||||
mcpServerArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
@@ -52,7 +82,11 @@ async function main() {
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PORT: SERVER_PORT },
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
37
client/jest.config.cjs
Normal file
37
client/jest.config.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"^../components/DynamicJsonForm$":
|
||||
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
||||
"^../../components/DynamicJsonForm$":
|
||||
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
||||
},
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
useESM: true,
|
||||
jsx: "react-jsx",
|
||||
tsconfig: "tsconfig.jest.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
|
||||
// Exclude directories and files that don't need to be tested
|
||||
testPathIgnorePatterns: [
|
||||
"/node_modules/",
|
||||
"/dist/",
|
||||
"/bin/",
|
||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||
],
|
||||
// Exclude the same patterns from coverage reports
|
||||
coveragePathIgnorePatterns: [
|
||||
"/node_modules/",
|
||||
"/dist/",
|
||||
"/bin/",
|
||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||
],
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -19,21 +19,29 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"test:watch": "jest --config jest.config.cjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||
"@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",
|
||||
"pkce-challenge": "^4.1.0",
|
||||
"prismjs": "^1.29.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",
|
||||
@@ -42,25 +50,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/serve-handler": "^6.1.4",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"co": "^4.6.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-jest": "^29.2.6",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.8"
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||
import { useConnection } from "./lib/hooks/useConnection";
|
||||
import {
|
||||
ClientRequest,
|
||||
CompatibilityCallToolResult,
|
||||
@@ -10,15 +8,18 @@ import {
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Root,
|
||||
ServerNotification,
|
||||
Tool,
|
||||
LoggingLevel,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { 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 { StdErrNotification } from "./lib/notificationTypes";
|
||||
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import { z } from "zod";
|
||||
import "./App.css";
|
||||
import ConsoleTab from "./components/ConsoleTab";
|
||||
@@ -46,9 +48,20 @@ import ToolsTab from "./components/ToolsTab";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const PROXY_PORT = params.get("proxyPort") ?? "3000";
|
||||
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
|
||||
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
|
||||
|
||||
const App = () => {
|
||||
// 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[]
|
||||
@@ -71,14 +84,24 @@ const App = () => {
|
||||
return localStorage.getItem("lastArgs") || "";
|
||||
});
|
||||
|
||||
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
|
||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
||||
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 [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||
StdErrNotification[]
|
||||
>([]);
|
||||
const [roots, setRoots] = useState<Root[]>([]);
|
||||
const [env, setEnv] = useState<Record<string, string>>({});
|
||||
const [bearerToken, setBearerToken] = useState<string>(() => {
|
||||
return localStorage.getItem("lastBearerToken") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
@@ -110,6 +133,10 @@ const App = () => {
|
||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
||||
null,
|
||||
);
|
||||
const [resourceSubscriptions, setResourceSubscriptions] = useState<
|
||||
Set<string>
|
||||
>(new Set<string>());
|
||||
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
const [nextResourceCursor, setNextResourceCursor] = useState<
|
||||
@@ -133,6 +160,8 @@ const App = () => {
|
||||
requestHistory,
|
||||
makeRequest: makeConnectionRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
connect: connectMcpServer,
|
||||
} = useConnection({
|
||||
transportType,
|
||||
@@ -140,6 +169,7 @@ const App = () => {
|
||||
args,
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
proxyServerUrl: PROXY_SERVER_URL,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
@@ -159,29 +189,6 @@ const App = () => {
|
||||
getRoots: () => rootsRef.current,
|
||||
});
|
||||
|
||||
const makeRequest = async <T extends z.ZodType>(
|
||||
request: ClientRequest,
|
||||
schema: T,
|
||||
tabKey?: keyof typeof errors,
|
||||
) => {
|
||||
try {
|
||||
const response = await makeConnectionRequest(request, schema);
|
||||
if (tabKey !== undefined) {
|
||||
clearError(tabKey);
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
const errorString = (e as Error).message ?? String(e);
|
||||
if (tabKey !== undefined) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[tabKey]: errorString,
|
||||
}));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastCommand", command);
|
||||
}, [command]);
|
||||
@@ -190,6 +197,35 @@ const App = () => {
|
||||
localStorage.setItem("lastArgs", args);
|
||||
}, [args]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastSseUrl", sseUrl);
|
||||
}, [sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastTransportType", transportType);
|
||||
}, [transportType]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastBearerToken", bearerToken);
|
||||
}, [bearerToken]);
|
||||
|
||||
// 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())
|
||||
@@ -221,6 +257,29 @@ const App = () => {
|
||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||
};
|
||||
|
||||
const makeRequest = async <T extends z.ZodType>(
|
||||
request: ClientRequest,
|
||||
schema: T,
|
||||
tabKey?: keyof typeof errors,
|
||||
) => {
|
||||
try {
|
||||
const response = await makeConnectionRequest(request, schema);
|
||||
if (tabKey !== undefined) {
|
||||
clearError(tabKey);
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
const errorString = (e as Error).message ?? String(e);
|
||||
if (tabKey !== undefined) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[tabKey]: errorString,
|
||||
}));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const listResources = async () => {
|
||||
const response = await makeRequest(
|
||||
{
|
||||
@@ -263,6 +322,38 @@ const App = () => {
|
||||
setResourceContent(JSON.stringify(response, null, 2));
|
||||
};
|
||||
|
||||
const subscribeToResource = async (uri: string) => {
|
||||
if (!resourceSubscriptions.has(uri)) {
|
||||
await makeRequest(
|
||||
{
|
||||
method: "resources/subscribe" as const,
|
||||
params: { uri },
|
||||
},
|
||||
z.object({}),
|
||||
"resources",
|
||||
);
|
||||
const clone = new Set(resourceSubscriptions);
|
||||
clone.add(uri);
|
||||
setResourceSubscriptions(clone);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeFromResource = async (uri: string) => {
|
||||
if (resourceSubscriptions.has(uri)) {
|
||||
await makeRequest(
|
||||
{
|
||||
method: "resources/unsubscribe" as const,
|
||||
params: { uri },
|
||||
},
|
||||
z.object({}),
|
||||
"resources",
|
||||
);
|
||||
const clone = new Set(resourceSubscriptions);
|
||||
clone.delete(uri);
|
||||
setResourceSubscriptions(clone);
|
||||
}
|
||||
};
|
||||
|
||||
const listPrompts = async () => {
|
||||
const response = await makeRequest(
|
||||
{
|
||||
@@ -323,6 +414,17 @@ const App = () => {
|
||||
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||
};
|
||||
|
||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||
await makeRequest(
|
||||
{
|
||||
method: "logging/setLevel" as const,
|
||||
params: { level },
|
||||
},
|
||||
z.object({}),
|
||||
);
|
||||
setLogLevel(level);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
@@ -337,8 +439,13 @@ const App = () => {
|
||||
setSseUrl={setSseUrl}
|
||||
env={env}
|
||||
setEnv={setEnv}
|
||||
bearerToken={bearerToken}
|
||||
setBearerToken={setBearerToken}
|
||||
onConnect={connectMcpServer}
|
||||
stdErrNotifications={stdErrNotifications}
|
||||
logLevel={logLevel}
|
||||
sendLogLevelRequest={sendLogLevelRequest}
|
||||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -440,6 +547,20 @@ const App = () => {
|
||||
clearError("resources");
|
||||
setSelectedResource(resource);
|
||||
}}
|
||||
resourceSubscriptionsSupported={
|
||||
serverCapabilities?.resources?.subscribe || false
|
||||
}
|
||||
resourceSubscriptions={resourceSubscriptions}
|
||||
subscribeToResource={(uri) => {
|
||||
clearError("resources");
|
||||
subscribeToResource(uri);
|
||||
}}
|
||||
unsubscribeFromResource={(uri) => {
|
||||
clearError("resources");
|
||||
unsubscribeFromResource(uri);
|
||||
}}
|
||||
handleCompletion={handleCompletion}
|
||||
completionsSupported={completionsSupported}
|
||||
resourceContent={resourceContent}
|
||||
nextCursor={nextResourceCursor}
|
||||
nextTemplateCursor={nextResourceTemplateCursor}
|
||||
@@ -464,6 +585,8 @@ const App = () => {
|
||||
clearError("prompts");
|
||||
setSelectedPrompt(prompt);
|
||||
}}
|
||||
handleCompletion={handleCompletion}
|
||||
completionsSupported={completionsSupported}
|
||||
promptContent={promptContent}
|
||||
nextCursor={nextPromptCursor}
|
||||
error={errors.prompts}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
import { useConnection } from "../lib/hooks/useConnection";
|
||||
import { useDraggablePane } from "../lib/hooks/useDraggablePane";
|
||||
|
||||
// Mock URL params
|
||||
const mockURLSearchParams = vi.fn();
|
||||
vi.stubGlobal("URLSearchParams", mockURLSearchParams);
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../lib/hooks/useConnection", () => ({
|
||||
useConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/hooks/useDraggablePane", () => ({
|
||||
useDraggablePane: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch for config
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock URL params
|
||||
mockURLSearchParams.mockReturnValue({
|
||||
get: () => "3000",
|
||||
});
|
||||
|
||||
// Mock fetch response
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
defaultEnvironment: {},
|
||||
defaultCommand: "test-command",
|
||||
defaultArgs: "--test",
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock useConnection hook
|
||||
const mockUseConnection = useConnection as jest.Mock;
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "disconnected",
|
||||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
makeRequest: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock useDraggablePane hook
|
||||
const mockUseDraggablePane = useDraggablePane as jest.Mock;
|
||||
mockUseDraggablePane.mockReturnValue({
|
||||
height: 300,
|
||||
handleDragStart: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders initial disconnected state", async () => {
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
expect(
|
||||
screen.getByText("Connect to an MCP server to start inspecting"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads config on mount", async () => {
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith("http://localhost:3000/config");
|
||||
});
|
||||
|
||||
it("shows connected interface when mcpClient is available", async () => {
|
||||
const mockUseConnection = useConnection as jest.Mock;
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
serverCapabilities: {
|
||||
resources: true,
|
||||
prompts: true,
|
||||
tools: true,
|
||||
},
|
||||
mcpClient: {},
|
||||
requestHistory: [],
|
||||
makeRequest: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
// Use more specific selectors
|
||||
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
|
||||
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
|
||||
const toolsTab = screen.getByRole("tab", { name: /tools/i });
|
||||
|
||||
expect(resourcesTab).toBeInTheDocument();
|
||||
expect(promptsTab).toBeInTheDocument();
|
||||
expect(toolsTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables tabs based on server capabilities", async () => {
|
||||
const mockUseConnection = useConnection as jest.Mock;
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
serverCapabilities: {
|
||||
resources: false,
|
||||
prompts: true,
|
||||
tools: false,
|
||||
},
|
||||
mcpClient: {},
|
||||
requestHistory: [],
|
||||
makeRequest: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
// Resources tab should be disabled
|
||||
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
|
||||
expect(resourcesTab).toHaveAttribute("disabled");
|
||||
|
||||
// Prompts tab should be enabled
|
||||
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
|
||||
expect(promptsTab).not.toHaveAttribute("disabled");
|
||||
|
||||
// Tools tab should be disabled
|
||||
const toolsTab = screen.getByRole("tab", { name: /tools/i });
|
||||
expect(toolsTab).toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
it("shows notification count in sampling tab", async () => {
|
||||
const mockUseConnection = useConnection as jest.Mock;
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
serverCapabilities: { sampling: true },
|
||||
mcpClient: {},
|
||||
requestHistory: [],
|
||||
makeRequest: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
onPendingRequest: (request, resolve, reject) => {
|
||||
// Simulate a pending request
|
||||
setPendingSampleRequests((prev) => [
|
||||
...prev,
|
||||
{ id: 1, request, resolve, reject },
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
// Initially no notification count
|
||||
const samplingTab = screen.getByRole("tab", { name: /sampling/i });
|
||||
expect(samplingTab.querySelector(".bg-red-500")).not.toBeInTheDocument();
|
||||
|
||||
// Simulate a pending request
|
||||
await act(async () => {
|
||||
mockUseConnection.mock.calls[0][0].onPendingRequest(
|
||||
{ method: "test", params: {} },
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
// Should show notification count
|
||||
expect(samplingTab.querySelector(".bg-red-500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists command and args to localStorage", async () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
// Simulate command change
|
||||
await act(async () => {
|
||||
const commandInput = screen.getByPlaceholderText(/command/i);
|
||||
fireEvent.change(commandInput, { target: { value: "new-command" } });
|
||||
});
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith("lastCommand", "new-command");
|
||||
});
|
||||
|
||||
it("shows error message when server has no capabilities", async () => {
|
||||
const mockUseConnection = useConnection as jest.Mock;
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
serverCapabilities: {},
|
||||
mcpClient: {},
|
||||
requestHistory: [],
|
||||
makeRequest: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<App />);
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The connected server does not support any MCP capabilities",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import HistoryAndNotifications from "../../components/History";
|
||||
|
||||
describe("HistoryAndNotifications", () => {
|
||||
const mockHistory = [
|
||||
{
|
||||
request: JSON.stringify({ method: "test1" }),
|
||||
response: JSON.stringify({ result: "output1" }),
|
||||
},
|
||||
{
|
||||
request: JSON.stringify({ method: "test2" }),
|
||||
response: JSON.stringify({ result: "output2" }),
|
||||
},
|
||||
];
|
||||
|
||||
it("renders history items", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
const items = screen.getAllByText(/test[12]/, { exact: false });
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("expands history item when clicked", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstItem = screen.getByText(/test1/, { exact: false });
|
||||
fireEvent.click(firstItem);
|
||||
expect(screen.getByText("Request:")).toBeInTheDocument();
|
||||
expect(screen.getByText(/output1/, { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders and expands server notifications", () => {
|
||||
const notifications = [
|
||||
{ method: "notify1", params: { data: "test data 1" } },
|
||||
{ method: "notify2", params: { data: "test data 2" } },
|
||||
];
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={notifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByText(/notify[12]/, { exact: false });
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
fireEvent.click(items[0]);
|
||||
expect(screen.getByText("Details:")).toBeInTheDocument();
|
||||
expect(screen.getByText(/test data/, { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ListPane from "../../components/ListPane";
|
||||
|
||||
describe("ListPane", () => {
|
||||
type TestItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const mockItems: TestItem[] = [
|
||||
{ id: 1, name: "Item 1" },
|
||||
{ id: 2, name: "Item 2" },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
items: mockItems,
|
||||
listItems: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
setSelectedItem: vi.fn(),
|
||||
renderItem: (item: TestItem) => (
|
||||
<>
|
||||
<span className="flex-1">{item.name}</span>
|
||||
<span className="text-sm text-gray-500">ID: {item.id}</span>
|
||||
</>
|
||||
),
|
||||
title: "Test Items",
|
||||
buttonText: "List Items",
|
||||
};
|
||||
|
||||
it("renders title and buttons", () => {
|
||||
render(<ListPane {...defaultProps} />);
|
||||
expect(screen.getByText("Test Items")).toBeInTheDocument();
|
||||
expect(screen.getByText("List Items")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clear")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders list of items using renderItem prop", () => {
|
||||
render(<ListPane {...defaultProps} />);
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls listItems when List Items button is clicked", () => {
|
||||
const listItems = vi.fn();
|
||||
render(<ListPane {...defaultProps} listItems={listItems} />);
|
||||
|
||||
fireEvent.click(screen.getByText("List Items"));
|
||||
expect(listItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls clearItems when Clear button is clicked", () => {
|
||||
const clearItems = vi.fn();
|
||||
render(<ListPane {...defaultProps} clearItems={clearItems} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Clear"));
|
||||
expect(clearItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls setSelectedItem when an item is clicked", () => {
|
||||
const setSelectedItem = vi.fn();
|
||||
render(<ListPane {...defaultProps} setSelectedItem={setSelectedItem} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Item 1"));
|
||||
expect(setSelectedItem).toHaveBeenCalledWith(mockItems[0]);
|
||||
});
|
||||
|
||||
it("disables Clear button when items array is empty", () => {
|
||||
render(<ListPane {...defaultProps} items={[]} />);
|
||||
expect(screen.getByText("Clear")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables List Items button when isButtonDisabled is true", () => {
|
||||
render(<ListPane {...defaultProps} isButtonDisabled={true} />);
|
||||
expect(screen.getByText("List Items")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables List Items button when isButtonDisabled is false", () => {
|
||||
render(<ListPane {...defaultProps} isButtonDisabled={false} />);
|
||||
expect(screen.getByText("List Items")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import PingTab from "../../components/PingTab";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
||||
describe("PingTab", () => {
|
||||
const renderWithTabs = (component: React.ReactElement) => {
|
||||
return render(<Tabs defaultValue="ping">{component}</Tabs>);
|
||||
};
|
||||
|
||||
it("renders the MEGA PING button", () => {
|
||||
renderWithTabs(<PingTab onPingClick={() => {}} />);
|
||||
const button = screen.getByRole("button", { name: /mega ping/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass(
|
||||
"bg-gradient-to-r",
|
||||
"from-purple-500",
|
||||
"to-pink-500",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes rocket and explosion emojis", () => {
|
||||
renderWithTabs(<PingTab onPingClick={() => {}} />);
|
||||
expect(screen.getByText("🚀")).toBeInTheDocument();
|
||||
expect(screen.getByText("💥")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onPingClick when button is clicked", () => {
|
||||
const onPingClick = vi.fn();
|
||||
renderWithTabs(<PingTab onPingClick={onPingClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /mega ping/i }));
|
||||
expect(onPingClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("has animation classes for visual feedback", () => {
|
||||
renderWithTabs(<PingTab onPingClick={() => {}} />);
|
||||
const button = screen.getByRole("button", { name: /mega ping/i });
|
||||
expect(button).toHaveClass(
|
||||
"animate-pulse",
|
||||
"hover:scale-110",
|
||||
"transition",
|
||||
);
|
||||
});
|
||||
|
||||
it("has focus styles for accessibility", () => {
|
||||
renderWithTabs(<PingTab onPingClick={() => {}} />);
|
||||
const button = screen.getByRole("button", { name: /mega ping/i });
|
||||
expect(button).toHaveClass("focus:outline-none", "focus:ring-4");
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import PromptsTab from "../../components/PromptsTab";
|
||||
import type { Prompt } from "../../components/PromptsTab";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
||||
describe("PromptsTab", () => {
|
||||
const mockPrompts: Prompt[] = [
|
||||
{
|
||||
name: "test-prompt-1",
|
||||
description: "Test prompt 1 description",
|
||||
arguments: [
|
||||
{ name: "arg1", description: "Argument 1", required: true },
|
||||
{ name: "arg2", description: "Argument 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "test-prompt-2",
|
||||
description: "Test prompt 2 description",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
prompts: mockPrompts,
|
||||
listPrompts: vi.fn(),
|
||||
clearPrompts: vi.fn(),
|
||||
getPrompt: vi.fn(),
|
||||
selectedPrompt: null,
|
||||
setSelectedPrompt: vi.fn(),
|
||||
promptContent: "",
|
||||
nextCursor: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const renderWithTabs = (component: React.ReactElement) => {
|
||||
return render(<Tabs defaultValue="prompts">{component}</Tabs>);
|
||||
};
|
||||
|
||||
it("renders list of prompts", () => {
|
||||
renderWithTabs(<PromptsTab {...defaultProps} />);
|
||||
expect(screen.getByText("test-prompt-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-prompt-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prompt details when selected", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedPrompt: mockPrompts[0],
|
||||
};
|
||||
renderWithTabs(<PromptsTab {...props} />);
|
||||
expect(
|
||||
screen.getByText("Test prompt 1 description", {
|
||||
selector: "p.text-sm.text-gray-600",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("arg1")).toBeInTheDocument();
|
||||
expect(screen.getByText("arg2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles argument input", () => {
|
||||
const getPrompt = vi.fn();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedPrompt: mockPrompts[0],
|
||||
getPrompt,
|
||||
};
|
||||
renderWithTabs(<PromptsTab {...props} />);
|
||||
|
||||
const arg1Input = screen.getByPlaceholderText("Enter arg1");
|
||||
fireEvent.change(arg1Input, { target: { value: "test value" } });
|
||||
|
||||
const getPromptButton = screen.getByText("Get Prompt");
|
||||
fireEvent.click(getPromptButton);
|
||||
|
||||
expect(getPrompt).toHaveBeenCalledWith("test-prompt-1", {
|
||||
arg1: "test value",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when error prop is provided", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
error: "Test error message",
|
||||
};
|
||||
renderWithTabs(<PromptsTab {...props} />);
|
||||
expect(screen.getByText("Test error message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows prompt content when provided", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedPrompt: mockPrompts[0],
|
||||
promptContent: "Test prompt content",
|
||||
};
|
||||
renderWithTabs(<PromptsTab {...props} />);
|
||||
expect(screen.getByText("Test prompt content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ResourcesTab from "../../components/ResourcesTab";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import type {
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
describe("ResourcesTab", () => {
|
||||
const mockResources: Resource[] = [
|
||||
{ uri: "file:///test1.txt", name: "Test 1" },
|
||||
{ uri: "file:///test2.txt", name: "Test 2" },
|
||||
];
|
||||
|
||||
const mockTemplates: ResourceTemplate[] = [
|
||||
{
|
||||
name: "Template 1",
|
||||
description: "Test template 1",
|
||||
uriTemplate: "file:///test/{param1}/{param2}.txt",
|
||||
},
|
||||
{
|
||||
name: "Template 2",
|
||||
description: "Test template 2",
|
||||
uriTemplate: "file:///other/{name}.txt",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
resources: mockResources,
|
||||
resourceTemplates: mockTemplates,
|
||||
listResources: vi.fn(),
|
||||
clearResources: vi.fn(),
|
||||
listResourceTemplates: vi.fn(),
|
||||
clearResourceTemplates: vi.fn(),
|
||||
readResource: vi.fn(),
|
||||
selectedResource: null,
|
||||
setSelectedResource: vi.fn(),
|
||||
resourceContent: "",
|
||||
nextCursor: null,
|
||||
nextTemplateCursor: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const renderWithTabs = (component: React.ReactElement) => {
|
||||
return render(<Tabs defaultValue="resources">{component}</Tabs>);
|
||||
};
|
||||
|
||||
it("renders resources list", () => {
|
||||
renderWithTabs(<ResourcesTab {...defaultProps} />);
|
||||
expect(screen.getByText("Test 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders templates list", () => {
|
||||
renderWithTabs(<ResourcesTab {...defaultProps} />);
|
||||
expect(screen.getByText("Template 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Template 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows resource content when resource is selected", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedResource: mockResources[0],
|
||||
resourceContent: "Test content",
|
||||
};
|
||||
renderWithTabs(<ResourcesTab {...props} />);
|
||||
expect(screen.getByText("Test content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows template form when template is selected", () => {
|
||||
renderWithTabs(<ResourcesTab {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Template 1"));
|
||||
expect(screen.getByText("Test template 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("param1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("param2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fills template and reads resource", () => {
|
||||
const readResource = vi.fn();
|
||||
const setSelectedResource = vi.fn();
|
||||
renderWithTabs(
|
||||
<ResourcesTab
|
||||
{...defaultProps}
|
||||
readResource={readResource}
|
||||
setSelectedResource={setSelectedResource}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select template
|
||||
fireEvent.click(screen.getByText("Template 1"));
|
||||
|
||||
// Fill in template parameters
|
||||
fireEvent.change(screen.getByLabelText("param1"), {
|
||||
target: { value: "value1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("param2"), {
|
||||
target: { value: "value2" },
|
||||
});
|
||||
|
||||
// Submit form
|
||||
fireEvent.click(screen.getByText("Read Resource"));
|
||||
|
||||
expect(readResource).toHaveBeenCalledWith("file:///test/value1/value2.txt");
|
||||
expect(setSelectedResource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uri: "file:///test/value1/value2.txt",
|
||||
name: "file:///test/value1/value2.txt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error message when error prop is provided", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
error: "Test error message",
|
||||
};
|
||||
renderWithTabs(<ResourcesTab {...props} />);
|
||||
expect(screen.getByText("Test error message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes resource content when refresh button is clicked", () => {
|
||||
const readResource = vi.fn();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedResource: mockResources[0],
|
||||
readResource,
|
||||
};
|
||||
renderWithTabs(<ResourcesTab {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Refresh"));
|
||||
expect(readResource).toHaveBeenCalledWith(mockResources[0].uri);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import RootsTab from "../../components/RootsTab";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
describe("RootsTab", () => {
|
||||
const mockRoots: Root[] = [
|
||||
{ uri: "file:///test/path1", name: "test1" },
|
||||
{ uri: "file:///test/path2", name: "test2" },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
roots: mockRoots,
|
||||
setRoots: vi.fn(),
|
||||
onRootsChange: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithTabs = (component: React.ReactElement) => {
|
||||
return render(<Tabs defaultValue="roots">{component}</Tabs>);
|
||||
};
|
||||
|
||||
it("renders list of roots", () => {
|
||||
renderWithTabs(<RootsTab {...defaultProps} />);
|
||||
expect(screen.getByDisplayValue("file:///test/path1")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("file:///test/path2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a new root when Add Root button is clicked", () => {
|
||||
const setRoots = vi.fn();
|
||||
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Add Root"));
|
||||
|
||||
expect(setRoots).toHaveBeenCalled();
|
||||
const updateFn = setRoots.mock.calls[0][0];
|
||||
const result = updateFn(mockRoots);
|
||||
expect(result).toEqual([...mockRoots, { uri: "file://", name: "" }]);
|
||||
});
|
||||
|
||||
it("removes a root when remove button is clicked", () => {
|
||||
const setRoots = vi.fn();
|
||||
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole("button", {
|
||||
name: /remove root/i,
|
||||
});
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(setRoots).toHaveBeenCalled();
|
||||
const updateFn = setRoots.mock.calls[0][0];
|
||||
const result = updateFn(mockRoots);
|
||||
expect(result).toEqual([mockRoots[1]]);
|
||||
});
|
||||
|
||||
it("updates root URI when input changes", () => {
|
||||
const setRoots = vi.fn();
|
||||
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
|
||||
|
||||
const firstInput = screen.getByDisplayValue("file:///test/path1");
|
||||
fireEvent.change(firstInput, { target: { value: "file:///new/path" } });
|
||||
|
||||
expect(setRoots).toHaveBeenCalled();
|
||||
const updateFn = setRoots.mock.calls[0][0];
|
||||
const result = updateFn(mockRoots);
|
||||
expect(result[0].uri).toBe("file:///new/path");
|
||||
expect(result[1]).toEqual(mockRoots[1]);
|
||||
});
|
||||
|
||||
it("calls onRootsChange when Save Changes is clicked", () => {
|
||||
const onRootsChange = vi.fn();
|
||||
renderWithTabs(
|
||||
<RootsTab {...defaultProps} onRootsChange={onRootsChange} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Save Changes"));
|
||||
|
||||
expect(onRootsChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import SamplingTab from "../../components/SamplingTab";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import type { CreateMessageRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
describe("SamplingTab", () => {
|
||||
const mockRequest: CreateMessageRequest = {
|
||||
model: "test-model",
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Test message",
|
||||
},
|
||||
};
|
||||
|
||||
const mockPendingRequests = [
|
||||
{ id: 1, request: mockRequest },
|
||||
{
|
||||
id: 2,
|
||||
request: {
|
||||
...mockRequest,
|
||||
content: { type: "text", text: "Another test" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
pendingRequests: mockPendingRequests,
|
||||
onApprove: vi.fn(),
|
||||
onReject: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithTabs = (component: React.ReactElement) => {
|
||||
return render(<Tabs defaultValue="sampling">{component}</Tabs>);
|
||||
};
|
||||
|
||||
it("renders empty state when no requests", () => {
|
||||
renderWithTabs(<SamplingTab {...defaultProps} pendingRequests={[]} />);
|
||||
expect(screen.getByText("No pending requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders list of pending requests", () => {
|
||||
renderWithTabs(<SamplingTab {...defaultProps} />);
|
||||
expect(screen.getByText(/Test message/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Another test/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows request details in JSON format", () => {
|
||||
renderWithTabs(<SamplingTab {...defaultProps} />);
|
||||
const requestJson = screen.getAllByText((content) =>
|
||||
content.includes('"model": "test-model"'),
|
||||
);
|
||||
expect(requestJson).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onApprove with stub response when Approve is clicked", () => {
|
||||
const onApprove = vi.fn();
|
||||
renderWithTabs(<SamplingTab {...defaultProps} onApprove={onApprove} />);
|
||||
|
||||
const approveButtons = screen.getAllByText("Approve");
|
||||
fireEvent.click(approveButtons[0]);
|
||||
|
||||
expect(onApprove).toHaveBeenCalledWith(1, {
|
||||
model: "stub-model",
|
||||
stopReason: "endTurn",
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "This is a stub response.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onReject when Reject is clicked", () => {
|
||||
const onReject = vi.fn();
|
||||
renderWithTabs(<SamplingTab {...defaultProps} onReject={onReject} />);
|
||||
|
||||
const rejectButtons = screen.getAllByText("Reject");
|
||||
fireEvent.click(rejectButtons[0]);
|
||||
|
||||
expect(onReject).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("shows informational alert about sampling requests", () => {
|
||||
renderWithTabs(<SamplingTab {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText(/When the server requests LLM sampling/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { expect, afterEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
|
||||
// @ts-ignore
|
||||
expect.extend(matchers);
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock window.location.hash
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { hash: "" },
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = "";
|
||||
});
|
||||
401
client/src/components/DynamicJsonForm.tsx
Normal file
401
client/src/components/DynamicJsonForm.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
value: JsonValue;
|
||||
onChange: (value: JsonValue) => void;
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
maxDepth = 3,
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
// Store the raw JSON string to allow immediate feedback during typing
|
||||
// while deferring parsing until the user stops typing
|
||||
const [rawJsonValue, setRawJsonValue] = useState<string>(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
|
||||
// Use a ref to manage debouncing timeouts to avoid parsing JSON
|
||||
// on every keystroke which would be inefficient and error-prone
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Debounce JSON parsing and parent updates to handle typing gracefully
|
||||
const debouncedUpdateParent = useCallback(
|
||||
(jsonString: string) => {
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
onChange(parsed);
|
||||
setJsonError(undefined);
|
||||
} catch {
|
||||
// Don't set error during normal typing
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[onChange, setJsonError],
|
||||
);
|
||||
|
||||
// Update rawJsonValue when value prop changes
|
||||
useEffect(() => {
|
||||
if (!isJsonMode) {
|
||||
setRawJsonValue(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
}
|
||||
}, [value, schema, isJsonMode]);
|
||||
|
||||
const handleSwitchToFormMode = () => {
|
||||
if (isJsonMode) {
|
||||
// When switching to Form mode, ensure we have valid JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawJsonValue);
|
||||
// Update the parent component's state with the parsed value
|
||||
onChange(parsed);
|
||||
// Switch to form mode
|
||||
setIsJsonMode(false);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
} else {
|
||||
// Update raw JSON value when switching to JSON mode
|
||||
setRawJsonValue(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
setIsJsonMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
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":
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required fields by setting undefined
|
||||
// This preserves the distinction between empty string and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required number fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required integer fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
// Only update if it's a valid integer
|
||||
if (!isNaN(num) && Number.isInteger(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "object": {
|
||||
// Handle case where we have a value but no schema properties
|
||||
const objectValue = (currentValue as JsonObject) || {};
|
||||
|
||||
// If we have schema properties, use them to render fields
|
||||
if (propSchema.properties) {
|
||||
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,
|
||||
objectValue[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have a value but no schema properties, render fields based on the value
|
||||
else if (Object.keys(objectValue).length > 0) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(objectValue).map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange([...path, key], e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have neither schema properties nor value, return null
|
||||
return null;
|
||||
}
|
||||
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={() => {
|
||||
const defaultValue = generateDefaultValue(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [
|
||||
...arrayValue,
|
||||
defaultValue ?? null,
|
||||
]);
|
||||
}}
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const newValue = updateValueAtPath(value, path, fieldValue);
|
||||
onChange(newValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to update form value:", error);
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldUseJsonMode =
|
||||
schema.type === "object" &&
|
||||
(!schema.properties || Object.keys(schema.properties).length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUseJsonMode && !isJsonMode) {
|
||||
setIsJsonMode(true);
|
||||
}
|
||||
}, [shouldUseJsonMode, isJsonMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isJsonMode ? (
|
||||
<JsonEditor
|
||||
value={rawJsonValue}
|
||||
onChange={(newValue) => {
|
||||
// Always update local state
|
||||
setRawJsonValue(newValue);
|
||||
|
||||
// Use the debounced function to attempt parsing and updating parent
|
||||
debouncedUpdateParent(newValue);
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
|
||||
// render a simple representation of the JSON data
|
||||
schema.type === "object" &&
|
||||
(typeof value !== "object" ||
|
||||
value === null ||
|
||||
Object.keys(value).length === 0) &&
|
||||
rawJsonValue &&
|
||||
rawJsonValue !== "{}" ? (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Form view not available for this JSON structure. Using simplified
|
||||
view:
|
||||
</p>
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
|
||||
{rawJsonValue}
|
||||
</pre>
|
||||
<p className="text-sm text-gray-500">
|
||||
Use JSON mode for full editing capabilities.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderFormFields(schema, value)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicJsonForm;
|
||||
92
client/src/components/JsonEditor.tsx
Normal file
92
client/src/components/JsonEditor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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: externalError,
|
||||
}: JsonEditorProps) => {
|
||||
const [editorContent, setEditorContent] = useState(value);
|
||||
const [internalError, setInternalError] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorContent(value);
|
||||
}, [value]);
|
||||
|
||||
const formatJson = (json: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
} catch {
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorChange = (newContent: string) => {
|
||||
setEditorContent(newContent);
|
||||
setInternalError(undefined);
|
||||
onChange(newContent);
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
try {
|
||||
const formatted = formatJson(editorContent);
|
||||
setEditorContent(formatted);
|
||||
onChange(formatted);
|
||||
setInternalError(undefined);
|
||||
} catch (err) {
|
||||
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = internalError || externalError;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleFormatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`border rounded-md ${
|
||||
displayError
|
||||
? "border-red-500"
|
||||
: "border-gray-200 dark:border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Editor
|
||||
value={editorContent}
|
||||
onValueChange={handleEditorChange}
|
||||
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>
|
||||
{displayError && (
|
||||
<p className="text-sm text-red-500 mt-1">{displayError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonEditor;
|
||||
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;
|
||||
@@ -7,11 +7,9 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
||||
<div className="col-span-2 flex justify-center items-center">
|
||||
<Button
|
||||
onClick={onPingClick}
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-6 px-12 rounded-full shadow-lg transform transition duration-300 hover:scale-110 focus:outline-none focus:ring-4 focus:ring-purple-300 animate-pulse"
|
||||
className="font-bold py-6 px-12 rounded-full"
|
||||
>
|
||||
<span className="text-3xl mr-2">🚀</span>
|
||||
MEGA PING
|
||||
<span className="text-3xl ml-2">💥</span>
|
||||
Ping Server
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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;
|
||||
@@ -26,6 +31,8 @@ const PromptsTab = ({
|
||||
getPrompt,
|
||||
selectedPrompt,
|
||||
setSelectedPrompt,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
promptContent,
|
||||
nextCursor,
|
||||
error,
|
||||
@@ -36,14 +43,37 @@ const PromptsTab = ({
|
||||
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 = () => {
|
||||
@@ -96,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,16 +1,20 @@
|
||||
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,
|
||||
@@ -22,6 +26,12 @@ const ResourcesTab = ({
|
||||
readResource,
|
||||
selectedResource,
|
||||
setSelectedResource,
|
||||
resourceSubscriptionsSupported,
|
||||
resourceSubscriptions,
|
||||
subscribeToResource,
|
||||
unsubscribeFromResource,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
resourceContent,
|
||||
nextCursor,
|
||||
nextTemplateCursor,
|
||||
@@ -36,10 +46,20 @@ const ResourcesTab = ({
|
||||
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"];
|
||||
error: string | null;
|
||||
resourceSubscriptionsSupported: boolean;
|
||||
resourceSubscriptions: Set<string>;
|
||||
subscribeToResource: (uri: string) => void;
|
||||
unsubscribeFromResource: (uri: string) => void;
|
||||
}) => {
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<ResourceTemplate | null>(null);
|
||||
@@ -47,6 +67,13 @@ const ResourcesTab = ({
|
||||
{},
|
||||
);
|
||||
|
||||
const { completions, clearCompletions, requestCompletions } =
|
||||
useCompletionState(handleCompletion, completionsSupported);
|
||||
|
||||
useEffect(() => {
|
||||
clearCompletions();
|
||||
}, [clearCompletions]);
|
||||
|
||||
const fillTemplate = (
|
||||
template: string,
|
||||
values: Record<string, string>,
|
||||
@@ -57,6 +84,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);
|
||||
@@ -130,14 +172,38 @@ const ResourcesTab = ({
|
||||
: "Select a resource or template"}
|
||||
</h3>
|
||||
{selectedResource && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => readResource(selectedResource.uri)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex row-auto gap-1 justify-end w-2/5">
|
||||
{resourceSubscriptionsSupported &&
|
||||
!resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => subscribeToResource(selectedResource.uri)}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{resourceSubscriptionsSupported &&
|
||||
resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unsubscribeFromResource(selectedResource.uri)
|
||||
}
|
||||
>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => readResource(selectedResource.uri)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
@@ -162,22 +228,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>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,6 @@ const RootsTab = ({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeRoot(index)}
|
||||
aria-label="Remove root"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -43,7 +43,7 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||
<pre className="bg-gray-50 p-2 rounded">
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||
{JSON.stringify(request.request, null, 2)}
|
||||
</pre>
|
||||
<div className="flex space-x-2">
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
CircleHelp,
|
||||
Bug,
|
||||
Github,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,6 +19,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
||||
import {
|
||||
LoggingLevel,
|
||||
LoggingLevelSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import useTheme from "../lib/useTheme";
|
||||
import { version } from "../../../package.json";
|
||||
@@ -33,8 +39,13 @@ interface SidebarProps {
|
||||
setSseUrl: (url: string) => void;
|
||||
env: Record<string, string>;
|
||||
setEnv: (env: Record<string, string>) => void;
|
||||
bearerToken: string;
|
||||
setBearerToken: (token: string) => void;
|
||||
onConnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
logLevel: LoggingLevel;
|
||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||
loggingSupported: boolean;
|
||||
}
|
||||
|
||||
const Sidebar = ({
|
||||
@@ -49,11 +60,18 @@ const Sidebar = ({
|
||||
setSseUrl,
|
||||
env,
|
||||
setEnv,
|
||||
bearerToken,
|
||||
setBearerToken,
|
||||
onConnect,
|
||||
stdErrNotifications,
|
||||
logLevel,
|
||||
sendLogLevelRequest,
|
||||
loggingSupported,
|
||||
}: SidebarProps) => {
|
||||
const [theme, setTheme] = useTheme();
|
||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||
const [showBearerToken, setShowBearerToken] = 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">
|
||||
@@ -107,15 +125,43 @@ const Sidebar = ({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">URL</label>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={sseUrl}
|
||||
onChange={(e) => setSseUrl(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">URL</label>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={sseUrl}
|
||||
onChange={(e) => setSseUrl(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
>
|
||||
{showBearerToken ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Authentication
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bearer Token</label>
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{transportType === "stdio" && (
|
||||
<div className="space-y-2">
|
||||
@@ -134,20 +180,52 @@ 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 newEnv = { ...env };
|
||||
delete newEnv[key];
|
||||
newEnv[e.target.value] = value;
|
||||
const newKey = e.target.value;
|
||||
const newEnv = Object.entries(env).reduce(
|
||||
(acc, [k, v]) => {
|
||||
if (k === key) {
|
||||
acc[newKey] = value;
|
||||
} else {
|
||||
acc[k] = v;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
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) => {
|
||||
@@ -157,24 +235,45 @@ const Sidebar = ({
|
||||
}}
|
||||
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);
|
||||
}}
|
||||
>
|
||||
@@ -209,6 +308,28 @@ const Sidebar = ({
|
||||
: "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loggingSupported && connectionStatus === "connected" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Logging Level</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={(value: LoggingLevel) =>
|
||||
sendLogLevelRequest(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select logging level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||
<SelectItem value={level}>{level}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stdErrNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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 { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
CallToolResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AlertCircle, Send } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
|
||||
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { escapeUnicode } from "@/utils/escapeUnicode";
|
||||
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
@@ -51,7 +54,7 @@ const ToolsTab = ({
|
||||
<>
|
||||
<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)}
|
||||
{escapeUnicode(toolResult)}
|
||||
</pre>
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
@@ -59,7 +62,7 @@ const ToolsTab = ({
|
||||
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)}
|
||||
{escapeUnicode(error)}
|
||||
</pre>
|
||||
))}
|
||||
</>
|
||||
@@ -87,11 +90,20 @@ const ToolsTab = ({
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" && (
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
|
||||
{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">
|
||||
{escapeUnicode(item.resource)}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -101,7 +113,7 @@ const ToolsTab = ({
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</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.toolResult, null, 2)}
|
||||
{escapeUnicode(toolResult.toolResult)}
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
@@ -150,22 +162,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,
|
||||
@@ -174,54 +206,49 @@ const ToolsTab = ({
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : /* @ts-expect-error value type is currently unknown */
|
||||
value.type === "object" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
name={key}
|
||||
// @ts-expect-error value type is currently unknown
|
||||
placeholder={value.description}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
setParams({
|
||||
...params,
|
||||
[key]: parsed,
|
||||
});
|
||||
} catch (err) {
|
||||
// If invalid JSON, store as string - will be validated on submit
|
||||
setParams({
|
||||
...params,
|
||||
[key]: e.target.value,
|
||||
});
|
||||
) : 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) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
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}
|
||||
value={(params[key] as string) ?? ""}
|
||||
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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,37 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
CreateMessageRequestSchema,
|
||||
ListRootsRequestSchema,
|
||||
ProgressNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
Request,
|
||||
Result,
|
||||
ServerCapabilities,
|
||||
PromptReference,
|
||||
ResourceReference,
|
||||
McpError,
|
||||
CompleteResultSchema,
|
||||
ErrorCode,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
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";
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const DEFAULT_REQUEST_TIMEOUT_MSEC =
|
||||
parseInt(params.get("timeout") ?? "") || 10000;
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse";
|
||||
@@ -24,6 +40,7 @@ interface UseConnectionOptions {
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
proxyServerUrl: string;
|
||||
bearerToken?: string;
|
||||
requestTimeout?: number;
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
@@ -31,6 +48,12 @@ interface UseConnectionOptions {
|
||||
getRoots?: () => any[];
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
export function useConnection({
|
||||
transportType,
|
||||
command,
|
||||
@@ -38,6 +61,7 @@ export function useConnection({
|
||||
sseUrl,
|
||||
env,
|
||||
proxyServerUrl,
|
||||
bearerToken,
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
@@ -53,6 +77,7 @@ export function useConnection({
|
||||
const [requestHistory, setRequestHistory] = useState<
|
||||
{ request: string; response?: string }[]
|
||||
>([]);
|
||||
const [completionsSupported, setCompletionsSupported] = useState(true);
|
||||
|
||||
const pushHistory = (request: object, response?: object) => {
|
||||
setRequestHistory((prev) => [
|
||||
@@ -67,7 +92,8 @@ export function useConnection({
|
||||
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");
|
||||
}
|
||||
@@ -76,12 +102,12 @@ export function useConnection({
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort("Request timed out");
|
||||
}, requestTimeout);
|
||||
}, options?.timeout ?? requestTimeout);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await mcpClient.request(request, schema, {
|
||||
signal: abortController.signal,
|
||||
signal: options?.signal ?? abortController.signal,
|
||||
});
|
||||
pushHistory(request, response);
|
||||
} catch (error) {
|
||||
@@ -95,33 +121,93 @@ export function useConnection({
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
const errorString = (e as Error).message ?? String(e);
|
||||
toast.error(errorString);
|
||||
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) {
|
||||
throw new Error("MCP client not connected");
|
||||
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) {
|
||||
toast.error((e as Error).message ?? String(e));
|
||||
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 connect = async () => {
|
||||
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",
|
||||
version: packageJson.version,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -144,13 +230,40 @@ export function useConnection({
|
||||
backendUrl.searchParams.append("url", sseUrl);
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(backendUrl);
|
||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||
// proxying through the inspector server first.
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(backendUrl, {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (onNotification) {
|
||||
client.setNotificationHandler(
|
||||
ProgressNotificationSchema,
|
||||
onNotification,
|
||||
);
|
||||
|
||||
client.setNotificationHandler(
|
||||
ResourceUpdatedNotificationSchema,
|
||||
onNotification,
|
||||
);
|
||||
|
||||
client.setNotificationHandler(
|
||||
LoggingMessageNotificationSchema,
|
||||
onNotification,
|
||||
);
|
||||
}
|
||||
|
||||
if (onStdErrNotification) {
|
||||
@@ -160,10 +273,25 @@ export function useConnection({
|
||||
);
|
||||
}
|
||||
|
||||
await client.connect(clientTransport);
|
||||
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) => {
|
||||
@@ -194,6 +322,8 @@ export function useConnection({
|
||||
requestHistory,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
completionsSupported,
|
||||
connect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
NotificationSchema as BaseNotificationSchema,
|
||||
ClientNotificationSchema,
|
||||
ServerNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -13,7 +14,7 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
|
||||
|
||||
export const NotificationSchema = ClientNotificationSchema.or(
|
||||
StdErrNotificationSchema,
|
||||
);
|
||||
).or(ServerNotificationSchema);
|
||||
|
||||
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
||||
export type Notification = z.infer<typeof NotificationSchema>;
|
||||
|
||||
27
client/src/utils/__tests__/escapeUnicode.test.ts
Normal file
27
client/src/utils/__tests__/escapeUnicode.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { escapeUnicode } from "../escapeUnicode";
|
||||
|
||||
describe("escapeUnicode", () => {
|
||||
it("should escape Unicode characters in a string", () => {
|
||||
const input = { text: "你好世界" };
|
||||
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
|
||||
expect(escapeUnicode(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
const input = { text: "" };
|
||||
const expected = '{\n "text": ""\n}';
|
||||
expect(escapeUnicode(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle null and undefined values", () => {
|
||||
const input = { text: null, value: undefined };
|
||||
const expected = '{\n "text": null\n}';
|
||||
expect(escapeUnicode(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle numbers and booleans", () => {
|
||||
const input = { number: 123, boolean: true };
|
||||
const expected = '{\n "number": 123,\n "boolean": true\n}';
|
||||
expect(escapeUnicode(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
|
||||
import { JsonValue } from "../../components/DynamicJsonForm";
|
||||
|
||||
describe("updateValueAtPath", () => {
|
||||
// Basic functionality tests
|
||||
test("returns the new value when path is empty", () => {
|
||||
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
|
||||
});
|
||||
|
||||
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
||||
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
|
||||
foo: "bar",
|
||||
});
|
||||
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
|
||||
foo: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
||||
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
|
||||
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
|
||||
});
|
||||
|
||||
// Object update tests
|
||||
test("updates a simple object property", () => {
|
||||
const obj = { name: "John", age: 30 };
|
||||
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
|
||||
name: "John",
|
||||
age: 31,
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a nested object property", () => {
|
||||
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||
expect(
|
||||
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||
});
|
||||
|
||||
test("creates missing object properties", () => {
|
||||
const obj = { user: { name: "John" } };
|
||||
expect(
|
||||
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||
});
|
||||
|
||||
// Array update tests
|
||||
test("updates an array item", () => {
|
||||
const arr = [1, 2, 3, 4];
|
||||
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
|
||||
});
|
||||
|
||||
test("extends an array when index is out of bounds", () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
|
||||
|
||||
// Check overall array structure
|
||||
expect(result).toEqual([1, 2, 3, null, null, "new"]);
|
||||
|
||||
// Explicitly verify that indices 3 and 4 contain null, not undefined
|
||||
expect(result[3]).toBe(null);
|
||||
expect(result[4]).toBe(null);
|
||||
|
||||
// Verify these aren't "holes" in the array (important distinction)
|
||||
expect(3 in result).toBe(true);
|
||||
expect(4 in result).toBe(true);
|
||||
|
||||
// Verify the array has the correct length
|
||||
expect(result.length).toBe(6);
|
||||
|
||||
// Verify the array doesn't have holes by checking every index exists
|
||||
expect(result.every((_, index: number) => index in result)).toBe(true);
|
||||
});
|
||||
|
||||
test("updates a nested array item", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
|
||||
users: [{ name: "John" }, { name: "Janet" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling tests
|
||||
test("returns original value when trying to update a primitive with a path", () => {
|
||||
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||
const result = updateValueAtPath("string", ["foo"], "bar");
|
||||
expect(result).toBe("string");
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns original array when index is invalid", () => {
|
||||
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||
const arr = [1, 2, 3];
|
||||
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns original array when index is negative", () => {
|
||||
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||
const arr = [1, 2, 3];
|
||||
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles sparse arrays correctly by filling holes with null", () => {
|
||||
// Create a sparse array by deleting an element
|
||||
const sparseArr = [1, 2, 3];
|
||||
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
|
||||
|
||||
// Update a value beyond the array length
|
||||
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
|
||||
|
||||
// Check overall array structure
|
||||
expect(result).toEqual([1, null, 3, null, null, "new"]);
|
||||
|
||||
// Explicitly verify that index 1 (the hole) contains null, not undefined
|
||||
expect(result[1]).toBe(null);
|
||||
|
||||
// Verify this isn't a hole in the array
|
||||
expect(1 in result).toBe(true);
|
||||
|
||||
// Verify all indices contain null (not undefined)
|
||||
expect(result[1]).not.toBe(undefined);
|
||||
expect(result[3]).toBe(null);
|
||||
expect(result[4]).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValueAtPath", () => {
|
||||
test("returns the original value when path is empty", () => {
|
||||
const obj = { foo: "bar" };
|
||||
expect(getValueAtPath(obj, [])).toBe(obj);
|
||||
});
|
||||
|
||||
test("returns the value at a simple path", () => {
|
||||
const obj = { name: "John", age: 30 };
|
||||
expect(getValueAtPath(obj, ["name"])).toBe("John");
|
||||
});
|
||||
|
||||
test("returns the value at a nested path", () => {
|
||||
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
|
||||
});
|
||||
|
||||
test("returns default value when path does not exist", () => {
|
||||
const obj = { user: { name: "John" } };
|
||||
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
|
||||
"Unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns default value when input is null/undefined", () => {
|
||||
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
|
||||
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles array indices correctly", () => {
|
||||
const arr = ["a", "b", "c"];
|
||||
expect(getValueAtPath(arr, ["1"])).toBe("b");
|
||||
});
|
||||
|
||||
test("returns default value for out of bounds array indices", () => {
|
||||
const arr = ["a", "b", "c"];
|
||||
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
|
||||
});
|
||||
|
||||
test("returns default value for invalid array indices", () => {
|
||||
const arr = ["a", "b", "c"];
|
||||
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
|
||||
});
|
||||
|
||||
test("navigates through mixed object and array paths", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
|
||||
});
|
||||
});
|
||||
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||
import { JsonSchemaType } from "../../components/DynamicJsonForm";
|
||||
|
||||
describe("generateDefaultValue", () => {
|
||||
test("generates default string", () => {
|
||||
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
|
||||
});
|
||||
|
||||
test("generates default number", () => {
|
||||
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
|
||||
});
|
||||
|
||||
test("generates default integer", () => {
|
||||
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
|
||||
});
|
||||
|
||||
test("generates default boolean", () => {
|
||||
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("generates default array", () => {
|
||||
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates default empty object", () => {
|
||||
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("generates default null for unknown types", () => {
|
||||
// @ts-expect-error Testing with invalid type
|
||||
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("generates empty array for non-required array", () => {
|
||||
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("generates empty object for non-required object", () => {
|
||||
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("generates null for non-required primitive types", () => {
|
||||
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
||||
null,
|
||||
);
|
||||
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
||||
null,
|
||||
);
|
||||
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("generates object with properties", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
required: true,
|
||||
properties: {
|
||||
name: { type: "string", required: true },
|
||||
age: { type: "number", required: true },
|
||||
isActive: { type: "boolean", required: true },
|
||||
},
|
||||
};
|
||||
expect(generateDefaultValue(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles nested objects", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
required: true,
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
required: true,
|
||||
properties: {
|
||||
name: { type: "string", required: true },
|
||||
address: {
|
||||
type: "object",
|
||||
required: true,
|
||||
properties: {
|
||||
city: { type: "string", required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(generateDefaultValue(schema)).toEqual({
|
||||
user: {
|
||||
name: "",
|
||||
address: {
|
||||
city: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses schema default value when provided", () => {
|
||||
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
|
||||
"test",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFieldLabel", () => {
|
||||
test("formats camelCase", () => {
|
||||
expect(formatFieldLabel("firstName")).toBe("First Name");
|
||||
});
|
||||
|
||||
test("formats snake_case", () => {
|
||||
expect(formatFieldLabel("first_name")).toBe("First name");
|
||||
});
|
||||
|
||||
test("formats single word", () => {
|
||||
expect(formatFieldLabel("name")).toBe("Name");
|
||||
});
|
||||
|
||||
test("formats mixed case with underscores", () => {
|
||||
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(formatFieldLabel("")).toBe("");
|
||||
});
|
||||
});
|
||||
16
client/src/utils/escapeUnicode.ts
Normal file
16
client/src/utils/escapeUnicode.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Utility function to escape Unicode characters
|
||||
export function escapeUnicode(obj: unknown): string {
|
||||
return JSON.stringify(
|
||||
obj,
|
||||
(_key: string, value) => {
|
||||
if (typeof value === "string") {
|
||||
// Replace non-ASCII characters with their Unicode escape sequences
|
||||
return value.replace(/[^\0-\x7F]/g, (char) => {
|
||||
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
|
||||
});
|
||||
}
|
||||
return value;
|
||||
},
|
||||
2,
|
||||
);
|
||||
}
|
||||
149
client/src/utils/jsonPathUtils.ts
Normal file
149
client/src/utils/jsonPathUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { JsonValue } from "../components/DynamicJsonForm";
|
||||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Updates a value at a specific path in a nested JSON structure
|
||||
* @param obj The original JSON value
|
||||
* @param path Array of keys/indices representing the path to the value
|
||||
* @param value The new value to set
|
||||
* @returns A new JSON value with the updated path
|
||||
*/
|
||||
export function updateValueAtPath(
|
||||
obj: JsonValue,
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue {
|
||||
if (path.length === 0) return value;
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
obj = !isNaN(Number(path[0])) ? [] : {};
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return updateArray(obj, path, value);
|
||||
} else if (typeof obj === "object" && obj !== null) {
|
||||
return updateObject(obj as JsonObject, path, value);
|
||||
} else {
|
||||
console.error(
|
||||
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
||||
obj,
|
||||
);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an array at a specific path
|
||||
*/
|
||||
function updateArray(
|
||||
array: JsonValue[],
|
||||
path: string[],
|
||||
value: JsonValue,
|
||||
): JsonValue[] {
|
||||
const [index, ...restPath] = path;
|
||||
const arrayIndex = Number(index);
|
||||
|
||||
if (isNaN(arrayIndex)) {
|
||||
console.error(`Invalid array index: ${index}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
if (arrayIndex < 0) {
|
||||
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
||||
return array;
|
||||
}
|
||||
|
||||
let newArray: JsonValue[] = [];
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
newArray[i] = i in array ? array[i] : null;
|
||||
}
|
||||
|
||||
if (arrayIndex >= newArray.length) {
|
||||
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
|
||||
// Copy over the existing elements (now guaranteed to be dense)
|
||||
for (let i = 0; i < newArray.length; i++) {
|
||||
extendedArray[i] = newArray[i];
|
||||
}
|
||||
newArray = extendedArray;
|
||||
}
|
||||
|
||||
if (restPath.length === 0) {
|
||||
newArray[arrayIndex] = value;
|
||||
} else {
|
||||
newArray[arrayIndex] = updateValueAtPath(
|
||||
newArray[arrayIndex],
|
||||
restPath,
|
||||
value,
|
||||
);
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an object at a specific path
|
||||
*/
|
||||
function 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)) {
|
||||
newObj[key] = {};
|
||||
}
|
||||
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a value at a specific path in a nested JSON structure
|
||||
* @param obj The JSON value to traverse
|
||||
* @param path Array of keys/indices representing the path to the value
|
||||
* @param defaultValue Value to return if path doesn't exist
|
||||
* @returns The value at the path, or defaultValue if not found
|
||||
*/
|
||||
export function getValueAtPath(
|
||||
obj: JsonValue,
|
||||
path: string[],
|
||||
defaultValue: JsonValue = null,
|
||||
): JsonValue {
|
||||
if (path.length === 0) return obj;
|
||||
|
||||
const [first, ...rest] = path;
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const index = Number(first);
|
||||
if (isNaN(index) || index < 0 || index >= obj.length) {
|
||||
return defaultValue;
|
||||
}
|
||||
return getValueAtPath(obj[index], rest, defaultValue);
|
||||
}
|
||||
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
if (!(first in obj)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
57
client/src/utils/schemaUtils.ts
Normal file
57
client/src/utils/schemaUtils.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
|
||||
import { JsonObject } from "./jsonPathUtils";
|
||||
|
||||
/**
|
||||
* Generates a default value based on a JSON schema type
|
||||
* @param schema The JSON schema definition
|
||||
* @returns A default value matching the schema type, or null for non-required fields
|
||||
*/
|
||||
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
||||
if ("default" in schema) {
|
||||
return schema.default;
|
||||
}
|
||||
|
||||
if (!schema.required) {
|
||||
if (schema.type === "array") return [];
|
||||
if (schema.type === "object") return {};
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object": {
|
||||
if (!schema.properties) return {};
|
||||
|
||||
const obj: JsonObject = {};
|
||||
Object.entries(schema.properties)
|
||||
.filter(([, prop]) => prop.required)
|
||||
.forEach(([key, prop]) => {
|
||||
const value = generateDefaultValue(prop);
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a field key into a human-readable label
|
||||
* @param key The field key to format
|
||||
* @returns A formatted label string
|
||||
*/
|
||||
export function 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
|
||||
}
|
||||
10
client/tsconfig.jest.json
Normal file
10
client/tsconfig.jest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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: {
|
||||
host: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
@@ -14,8 +17,8 @@ export default defineConfig({
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup/setup.ts'],
|
||||
include: ['src/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
},
|
||||
})
|
||||
4939
package-lock.json
generated
4939
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -22,6 +22,7 @@
|
||||
],
|
||||
"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",
|
||||
@@ -33,14 +34,15 @@
|
||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-client": "0.3.0",
|
||||
"@modelcontextprotocol/inspector-server": "0.3.0",
|
||||
"@modelcontextprotocol/inspector-client": "^0.7.0",
|
||||
"@modelcontextprotocol/inspector-server": "^0.7.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"shell-quote": "^1.8.2",
|
||||
"spawn-rx": "^5.1.0",
|
||||
"spawn-rx": "^5.1.2",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@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.3.0",
|
||||
"version": "0.7.0",
|
||||
"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": "^1.0.3",
|
||||
"@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,23 +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 mcpProxy from "./mcpProxy.js";
|
||||
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),
|
||||
@@ -32,7 +37,8 @@ app.use(cors());
|
||||
|
||||
let webAppTransports: SSEServerTransport[] = [];
|
||||
|
||||
const createTransport = async (query: express.Request["query"]) => {
|
||||
const createTransport = async (req: express.Request) => {
|
||||
const query = req.query;
|
||||
console.log("Query parameters:", query);
|
||||
|
||||
const transportType = query.transportType as string;
|
||||
@@ -40,13 +46,12 @@ const createTransport = async (query: express.Request["query"]) => {
|
||||
if (transportType === "stdio") {
|
||||
const command = query.command as string;
|
||||
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||
const env = query.env ? JSON.parse(query.env as string) : undefined;
|
||||
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
|
||||
|
||||
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||
|
||||
console.log(
|
||||
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
|
||||
);
|
||||
console.log(`Stdio transport: command=${cmd}, args=${args}`);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
@@ -61,9 +66,28 @@ const createTransport = async (query: express.Request["query"]) => {
|
||||
return transport;
|
||||
} else if (transportType === "sse") {
|
||||
const url = query.url as string;
|
||||
console.log(`SSE transport: url=${url}`);
|
||||
const headers: HeadersInit = {
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||
if (req.headers[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transport = new SSEClientTransport(new URL(url));
|
||||
const value = req.headers[key];
|
||||
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||
}
|
||||
|
||||
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
|
||||
|
||||
const transport = new SSEClientTransport(new URL(url), {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
console.log("Connected to SSE transport");
|
||||
@@ -78,7 +102,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");
|
||||
|
||||
@@ -105,9 +143,6 @@ app.get("/sse", async (req, res) => {
|
||||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: backingServerTransport,
|
||||
onerror: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Set up MCP proxy");
|
||||
@@ -136,8 +171,6 @@ app.post("/message", async (req, res) => {
|
||||
|
||||
app.get("/config", (req, res) => {
|
||||
try {
|
||||
const defaultEnvironment = getDefaultEnvironment();
|
||||
|
||||
res.json({
|
||||
defaultEnvironment,
|
||||
defaultCommand: values.env,
|
||||
@@ -150,4 +183,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