Compare commits

...

152 Commits

Author SHA1 Message Date
Cliff Hall
3032a67d4e Merge pull request #261 from cliffhall/bump-to-version-0.8.1
Bump to version 0.8.1 - package-lock.json
2025-04-03 18:07:31 -04:00
Cliff Hall
f0651baf4a Merge branch 'modelcontextprotocol:main' into bump-to-version-0.8.1 2025-04-03 16:33:52 -04:00
cliffhall
3f73ec83a2 Bump version to 0.8.1
* package-lock.json
2025-04-03 16:32:59 -04:00
Cliff Hall
bdab26dbeb Merge pull request #260 from cliffhall/bump-to-version-0.8.1
Bump version to 0.8.1
2025-04-03 15:36:00 -04:00
Cliff Hall
0f075af42c Merge branch 'main' into bump-to-version-0.8.1 2025-04-03 14:48:47 -04:00
Cliff Hall
06fcc74638 Merge pull request #247 from max-stytch/fix/use-https-protocol
fix: Use same protocol for proxy server URL
2025-04-03 14:48:02 -04:00
cliffhall
9092c780f7 Bump version to 0.8.1
* In client/package.json
  - version bumped to 0.8.1
  - typescript SDK version 0.8.0 (latest)
* In server/package.json
  - version bumped to 0.8.1
  - typescript SDK version 0.8.0 (latest)
* In client/package.json
  - version bumped to 0.8.1
  - inspector-client bumped to 0.8.1
  - inspector-server bumped to 0.8.1
2025-04-03 14:30:59 -04:00
Cliff Hall
a75dd7ba1f Merge pull request #253 from max-stytch/max/fix-reloads
fix: Do not reconnect on rerender
2025-04-03 13:38:11 -04:00
Maxwell Gerber
a414033354 fix: Use same protocol for proxy server URL 2025-04-03 09:18:11 -07:00
Cliff Hall
ce1a9d3905 Merge pull request #243 from pulkitsharma07/add_proxy_config
Add MCP proxy address config support, better error messages, redesigned Config panel
2025-04-02 18:40:57 -04:00
Maxwell Gerber
0bd51fa84a fix: Do not reconnect on rerender 2025-04-02 15:18:33 -07:00
Pulkit Sharma
2a544294ba merge main 2025-04-03 00:47:21 +05:30
Cliff Hall
897e637db4 Merge pull request #244 from NicolasMontone/feat/new-alert-and-copy-button
feat: add new toast and add copy button to JSON.
2025-04-02 13:04:54 -04:00
NicolasMontone
5db5fc26c7 fix prettier 2025-04-02 10:42:52 -03:00
NicolasMontone
8b31f495ba fix unkown type. 2025-04-02 10:41:33 -03:00
NicolasMontone
c964ff5cfe Use copy button insde JSON view component 2025-04-02 10:39:39 -03:00
Pulkit Sharma
e69bfc58bc prettier-fix 2025-04-02 13:45:09 +05:30
Pulkit Sharma
debb00344a Merge branch 'main' into add_proxy_config 2025-04-02 13:43:42 +05:30
NicolasMontone
c9ee22b781 fix(Select): add missing style. 2025-04-01 19:23:57 -03:00
NicolasMontone
cc70fbd0f5 add ring color 2025-04-01 19:14:32 -03:00
NicolasMontone
8586d63e6d Add shadcn alert to match project design 2025-04-01 17:38:25 -03:00
NicolasMontone
539de0fd85 add copy button 2025-04-01 17:18:40 -03:00
Cliff Hall
f9cb2c1bd0 Merge pull request #241 from NicolasMontone/main
Do not remove form on error in tool call tab.
2025-04-01 16:04:53 -04:00
NicolasMontone
80f2986fd6 remove unneeded test 2025-04-01 16:58:32 -03:00
NicolasMontone
1504d1307e Remove error 2025-04-01 16:49:10 -03:00
NicolasMontone
d1e155f984 Revert "Add copy button to JSON & fix button styles override."
This reverts commit c48670f426.
2025-04-01 16:48:01 -03:00
NicolasMontone
c48670f426 Add copy button to JSON & fix button styles override. 2025-04-01 16:43:23 -03:00
Cliff Hall
e9a90b9caf Merge branch 'main' into main 2025-04-01 15:35:12 -04:00
Cliff Hall
affd207c9e Merge pull request #238 from max-stytch/feat/disconnect
feat: Add lightweight Disconnect button
2025-04-01 15:17:45 -04:00
Cliff Hall
4afe2d6adb Merge branch 'main' into feat/disconnect 2025-04-01 14:53:10 -04:00
Nicolás Montone
e4d4ff0148 Merge branch 'main' into main 2025-04-01 15:10:10 -03:00
Cliff Hall
d127ee86ce Merge pull request #242 from olaservo/bump-version
Bump version to 0.8.0
2025-04-01 13:23:36 -04:00
Cliff Hall
f7d39fb252 Merge branch 'main' into bump-version 2025-04-01 13:22:03 -04:00
Cliff Hall
8faadc0588 Merge pull request #235 from olaservo/add-tests
Add failing test to repro issue 154
2025-04-01 12:23:55 -04:00
Pulkit Sharma
0dcd10c1dd Update readme 2025-04-01 19:48:48 +05:30
Ola Hungerford
65f38a4827 Bump version to 0.8.0 2025-04-01 07:07:57 -07:00
NicolasMontone
51f2f72677 fix: add tests 2025-04-01 11:07:43 -03:00
NicolasMontone
d2db697d89 fix: prettier. 2025-04-01 11:00:13 -03:00
Ola Hungerford
93c9c74dc9 Fix formatting 2025-04-01 06:59:50 -07:00
NicolasMontone
dada1c4ba6 do not remove form with alert 2025-04-01 10:59:30 -03:00
Ola Hungerford
c3117d0fea Merge branch 'main' into add-tests 2025-04-01 06:57:01 -07:00
Ola Hungerford
992fdb33ed Merge pull request #234 from katopz/fix-integer
fix: prop.type not accept integer
2025-04-01 06:56:37 -07:00
Ola Hungerford
2a9230d507 Merge branch 'main' into fix-integer 2025-04-01 06:51:58 -07:00
Pulkit Sharma
51c7eda6a6 Add MCP proxy address config support, better error messages 2025-04-01 19:21:19 +05:30
Ola Hungerford
b82c744583 Use actual rendered element spinbutton in test 2025-04-01 06:50:23 -07:00
Maxwell Gerber
9ab213bc89 feat: Add lightweight Disconnect button 2025-03-31 18:54:44 -07:00
katopz
2ee0a53e36 fix: prettier 2025-04-01 09:27:24 +09:00
Cliff Hall
fa7f9c80cd Merge pull request #236 from modelcontextprotocol/dependabot/npm_and_yarn/npm_and_yarn-7a62469a90
Bump vite from 5.4.15 to 5.4.16 in the npm_and_yarn group across 1 directory
2025-03-31 17:19:26 -04:00
dependabot[bot]
7dc2c6fb58 Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.15 to 5.4.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 20:38:18 +00:00
Cliff Hall
f49b2d1442 Merge pull request #233 from seuros/main
refactor: Update default ports for MCPI client and MPCP server
2025-03-31 16:19:26 -04:00
Abdelkader Boudih
d1746f53a4 Update package.json 2025-03-31 20:11:40 +00:00
Abdelkader Boudih
b99cf276ae fix: linting 2025-03-31 18:34:12 +00:00
Abdelkader Boudih
6c67bf6d6d Merge branch 'main' into main 2025-03-31 18:32:18 +00:00
Abdelkader Boudih
80f5ab1136 fix: typo 2025-03-31 18:28:40 +00:00
Ola Hungerford
2f854304a7 Merge pull request #216 from Skn0tt/cleanup-old-transports
fix: clean up previous transport processes
2025-03-31 10:42:10 -07:00
Cliff Hall
6b63bacbcd Merge pull request #229 from modelcontextprotocol/dependabot/npm_and_yarn/npm_and_yarn-7631eb0223
Bump vite from 5.4.12 to 5.4.15 in the npm_and_yarn group across 1 directory
2025-03-31 13:21:19 -04:00
Cliff Hall
fb29ca0113 Merge branch 'main' into cleanup-old-transports 2025-03-31 12:36:34 -04:00
dependabot[bot]
83ceefca79 Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.12 to 5.4.15
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.15/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.15/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 16:30:35 +00:00
Cliff Hall
c3bd1fb1c6 Merge pull request #224 from cliffhall/fix-214
Gracefully handle proxy server and inspector client startup errors
2025-03-31 12:29:15 -04:00
Cliff Hall
45d8202de8 Merge branch 'main' into fix-214 2025-03-31 12:27:41 -04:00
Ola Hungerford
180760c4db Fix formatting 2025-03-31 09:16:25 -07:00
Simon Knott
538fc97289 fix prettier 2025-03-31 18:10:16 +02:00
Simon Knott
04442b52a2 Merge branch 'main' into cleanup-old-transports 2025-03-31 18:10:00 +02:00
Ola Hungerford
7753b275e5 Update test to fail more explicitly 2025-03-31 08:04:53 -07:00
Ola Hungerford
7ac1e40c9d Update tests 2025-03-31 07:51:28 -07:00
Ola Hungerford
d2696e48a5 Add failing test for integer input 2025-03-31 07:32:55 -07:00
katopz
7b055b6b9a fix: prop.type not accept integer 2025-03-31 18:29:09 +09:00
Abdelkader Boudih
da9dd09765 refactor: Update default ports for MCPI client and MPCP server
Changes the default ports used by the MCP Inspector client UI and the MCP Proxy server to avoid conflicts with common development ports and provide a memorable mnemonic based on T9 mapping.
2025-03-31 00:31:28 +00:00
Cliff Hall
539f32bf3b Merge pull request #204 from pulkitsharma07/main
Add "Configuration" support in UI for configuring the request timeout (and more things in the future)
2025-03-29 16:36:19 -04:00
Pulkit Sharma
c6174116b0 update screenshot 2025-03-29 23:39:06 +05:30
Pulkit Sharma
65fc6d0490 prettier-fix, add contribution guidelines 2025-03-29 23:31:46 +05:30
Pulkit Sharma
834eb0c934 add Sidebar tests 2025-03-29 22:31:57 +05:30
cliffhall
054741be03 Run prettier. 2025-03-29 12:36:03 -04:00
Cliff Hall
eaa8055fd1 Merge branch 'main' into fix-214 2025-03-29 12:34:20 -04:00
Pulkit Sharma
9e1186f5ac Document configuration in Readme and add TSDoc for the same. 2025-03-29 09:40:13 +05:30
Pulkit Sharma
81c0ef29ab Merge branch 'main' into main 2025-03-29 09:11:01 +05:30
Ola Hungerford
75537c7cae Merge pull request #218 from cliffhall/fix-rules-of-hooks-warning
Fix ESLint rules-of-hooks warnings
2025-03-28 20:01:23 -07:00
Cliff Hall
dbaa731097 Merge branch 'main' into fix-rules-of-hooks-warning 2025-03-28 17:49:40 -04:00
Cliff Hall
dd0434c4ed Merge pull request #207 from samuel871211/perf_useTheme
perf: add `useMemo` to the return value of `useTheme`
2025-03-28 17:47:59 -04:00
Cliff Hall
ed31f9893f Merge branch 'main' into perf_useTheme 2025-03-28 17:46:00 -04:00
Cliff Hall
38fb710f2f Merge pull request #211 from cgoinglove/feat/json-view-component
feat: Add JSON View Component
2025-03-28 17:45:22 -04:00
Cliff Hall
240c67037c Merge branch 'main' into feat/json-view-component 2025-03-28 17:32:41 -04:00
Cliff Hall
02155570df Merge branch 'main' into cleanup-old-transports 2025-03-28 16:34:21 -04:00
cliffhall
7227909df3 Fix spurious linebreak 2025-03-28 16:03:39 -04:00
cliffhall
84335ae5f4 Remove else block wrapper for final component return to reduce indenting and hopefully make the PR diff easier to approve. 2025-03-28 15:57:11 -04:00
Pulkit Sharma
8486696240 Merge branch 'main' of github.com:pulkitsharma07/inspector 2025-03-29 01:23:29 +05:30
Pulkit Sharma
6a6b15ab45 don't disable rules-of-hooks 2025-03-29 01:22:35 +05:30
Cliff Hall
804a144ffb Merge branch 'main' into fix-rules-of-hooks-warning 2025-03-28 15:20:11 -04:00
Cliff Hall
1d4ca435b8 Merge branch 'main' into fix-214 2025-03-28 12:32:59 -04:00
cliffhall
1b754f52ca This fixes #214 where when the inspector is started, it can report that the inspector is up, when in fact it isn't because the address is already in use. It catches this condition, as well as the condition where the proxy server port is in use, reports it, and exits.
* In bin/cli.js
  - in the delay function, have the setTimeout return a true value.
  - try the server's spawnPromise call and within the try block, use Promise.race to get the return value of the first promise to resolve. If the server is up, it will not resolve and the 2 second delay will resolve with true, telling us the server is ok. Any error will have been reported by the server startup process, so we will not pile on with more output in the catch block.
  - If the server started ok, then we will await the spawnPromise call for starting the client. If the client fails to start it will report and exit, otherwise we are done and both servers are running and have reported as much. If an error is caught and it isn't SIGINT or if process.env.DEBUG is true then the error will be thrown before exiting.
* In client/bin/cli.js
  - add a "listening" handler to the server, logging that the MCP inspector is up at the given port
  - Add an "error" handler to the server that reports  that the client port is in use if the error message includes "EADDRINUSE", otherwise throw the error so the entire contents can be seen.
* In server/src/index.ts
  - add a "listening" handler to the server, logging that the Proxy server is up at the given port
  - Add an "error" handler to the server that reports  that the server port is in use if the error message includes "EADDRINUSE", otherwise throw the error so the entire contents can be seen.
* In package.json
  - in preview script
    - add --port 5173 to start the client on the proper port. This was useful in getting an instance of the client running before trying the start script. otherwise it starts on 4173
2025-03-28 11:59:21 -04:00
Ola Hungerford
e47b1d8f4d Merge pull request #200 from morinokami/patch-1
Restructure link buttons in sidebar to respect theme
2025-03-28 06:50:47 -07:00
Shinya Fujino
d2e211a597 Update Sidebar component icons to use 'text-foreground' for better visibility 2025-03-28 22:13:31 +09:00
Ola Hungerford
205e70736c Merge pull request #191 from nathanArseneau/show-all-notifications
Refactor notification handling to include all notifications
2025-03-28 06:04:39 -07:00
Shinya Fujino
827d867aae Merge branch 'main' into patch-1 2025-03-28 21:01:22 +09:00
Simon Knott
da0c855ef5 adapt title 2025-03-28 09:45:17 +01:00
cgoing
8180b0bd6f refactor: JsonEditor 2025-03-28 15:16:17 +09:00
yusheng chen
f09d2b6096 style: prettier format file client/src/lib/useTheme.ts 2025-03-28 07:05:22 +08:00
Cliff Hall
f846c154f5 Merge branch 'main' into perf_useTheme 2025-03-27 17:10:02 -04:00
Cliff Hall
9244ecf859 Merge branch 'main' into show-all-notifications 2025-03-27 16:42:44 -04:00
cliffhall
0891077402 This fixes the linter's warning that Hooks must be run in order each time.
* In App.tsx
  - move the conditional that returns suspense if the path is oauth/callback to the end of the component after all hooks, rendering either suspense or the normal component.
  - move handleApproveSampling and handleRejectSampling functions down below all the hooks for clarity. There are a lot of hooks so finding the end of them is a scroll, and these function constants aren't referenced until the rendering section below anyway.
2025-03-27 15:59:41 -04:00
Simon Knott
353d6b549b fix: clean up previous transport processes 2025-03-27 09:52:35 +01:00
cgoing
18ca6e28a7 Use <pre> tag inside JsonView component for consistency 2025-03-27 12:55:36 +09:00
cgoing
2d252a389c Remove escapeUnicode function from ToolsTab.tsx 2025-03-27 11:42:39 +09:00
cgoing
e6f5da8383 Improve JsonView component styling and change to use JsonView in PromptsTab 2025-03-27 10:49:37 +09:00
choi sung keun
196fd67ce9 Merge branch 'main' into feat/json-view-component 2025-03-27 09:40:29 +09:00
Cliff Hall
4d4bb9110c Merge pull request #208 from olaservo/update-readme
Add note on security considerations for proxy server
2025-03-26 17:43:52 -04:00
Cliff Hall
0d17082480 Merge branch 'main' into update-readme 2025-03-26 17:41:59 -04:00
Cliff Hall
cdf1f1508a Merge pull request #203 from olaservo/update-prism
Bump prismjs from 1.29.0 to 1.30.0
2025-03-26 16:22:54 -04:00
Cliff Hall
b97233f43a Merge branch 'main' into update-prism 2025-03-26 15:29:36 -04:00
choi sung keun
885932ac70 Merge branch 'main' into feat/json-view-component 2025-03-26 12:32:47 +09:00
Cliff Hall
00118f7cf9 Merge branch 'main' into show-all-notifications 2025-03-25 15:31:46 -04:00
Ola Hungerford
806cdb204f Merge pull request #186 from max-stytch/patch-1
fix: Prefer 127.0.0.1 over localhost
2025-03-25 09:13:16 -07:00
choi sung keun
fcc06ab556 Merge branch 'main' into feat/json-view-component 2025-03-26 00:11:57 +09:00
cgoing
03c1ba3092 Change JsonView default initialExpandDepth from 2 to 3 2025-03-26 00:11:07 +09:00
Max Gerber
029823bb92 Merge branch 'main' into patch-1 2025-03-25 11:05:42 -04:00
cgoing
2588f3aeb3 Change tooltip title from Korean to English 2025-03-26 00:02:10 +09:00
Ola Hungerford
88ffb5087e Merge pull request #210 from olaservo/add-ui-tests
Add preliminary UI tests
2025-03-25 07:03:30 -07:00
choi sung keun
1f17132ca1 Merge branch 'main' into feat/json-view-component 2025-03-25 22:14:29 +09:00
Pulkit Sharma
0e667acf7d Merge branch 'main' into main 2025-03-25 12:31:01 +05:30
choi sung keun
8e9e5facaf Merge branch 'main' into feat/json-view-component 2025-03-25 09:45:01 +09:00
Ola Hungerford
25f5bb7620 Merge branch 'main' into add-ui-tests 2025-03-24 12:01:41 -07:00
Pulkit Sharma
6efdcb626f Merge branch 'main' of github.com:pulkitsharma07/inspector 2025-03-24 23:30:57 +05:30
Pulkit Sharma
0656e15a22 add configuration types 2025-03-24 23:30:35 +05:30
cgoing
d204dd6e7e feat: json view component - dark color 2025-03-25 01:56:53 +09:00
cgoing
f0b28d4760 feat: json view component 2025-03-25 01:48:29 +09:00
Ola Hungerford
65a0d46816 Fix formatting 2025-03-24 09:47:34 -07:00
Ola Hungerford
951db44bad Merge branch 'add-ui-tests' of https://github.com/olaservo/inspector into add-ui-tests 2025-03-24 09:43:52 -07:00
Ola Hungerford
379486b5ea Add failing test for pull/206 2025-03-24 09:43:48 -07:00
Ola Hungerford
40213bb1ed Merge branch 'main' into add-ui-tests 2025-03-24 09:30:35 -07:00
Ola Hungerford
b7fa23676a Fix formatting 2025-03-24 09:27:51 -07:00
Ola Hungerford
a7f25153c4 Add failing ToolsTab test that should get fixed with pull/198 2025-03-24 09:18:58 -07:00
Ola Hungerford
fa3e2867c9 Fix formatting 2025-03-24 09:07:53 -07:00
Ola Hungerford
5735f2347a Add tests related to issues/187 to confirm fixed 2025-03-24 08:52:45 -07:00
Ola Hungerford
cab1ed3dd8 Add some json form tests and handle css in ui tests 2025-03-24 08:28:28 -07:00
Ola Hungerford
61e229a552 Add sidebar tests 2025-03-23 13:35:24 -07:00
Ola Hungerford
451704471c Remove setup 2025-03-23 13:25:06 -07:00
Ola Hungerford
668cc915e4 Add jest-dom types 2025-03-23 13:22:31 -07:00
Ola Hungerford
85f0e21679 Use commonjs for jest 2025-03-23 13:22:13 -07:00
Ola Hungerford
fc76a7c7d4 Add setup file and remove old testing mock that no longer exists from moduleNameMapper 2025-03-23 12:46:30 -07:00
Ola Hungerford
210975e385 Add test dependencies 2025-03-23 12:44:30 -07:00
Ola Hungerford
ec73831487 Fix formatting 2025-03-23 08:30:06 -07:00
Ola Hungerford
9b0da1f892 Add note on security considerations for proxy server 2025-03-23 08:19:52 -07:00
yusheng chen
3ac00598ff perf: add useMemo to the return value of useTheme 2025-03-23 21:29:46 +08:00
Pulkit Sharma
484e2820bc Merge branch 'main' into main 2025-03-23 11:08:55 +05:30
Pulkit Sharma
4a23585066 Add support in UI to configure request timeout 2025-03-22 21:18:38 +05:30
Ola Hungerford
dcbd1dad41 Bump prismjs from 1.29.0 to 1.30.0 to address 2025-03-21 06:54:46 -07:00
Shinya Fujino
ce81fb976b Restructure link buttons in sidebar to respect theme 2025-03-20 22:18:44 +09:00
Nathan Arseneau
27b54104c1 Add support for additional notification schemas in useConnection hook 2025-03-19 19:51:01 -04:00
Maxwell Gerber
536b7e0a99 fix: Update vite host 2025-03-18 09:29:48 -07:00
Nathan Arseneau
dd460bd877 Refactor notification handling to include all notifications 2025-03-17 02:44:59 -04:00
Maxwell Gerber
cda3905e5a fix: Update URL in CONTRIBUTING.md too 2025-03-14 16:33:39 -07:00
Max Gerber
fb667fd4d0 fix: Prefer 127.0.0.1 over localhost 2025-03-14 16:16:31 -07:00
39 changed files with 2608 additions and 2878 deletions

View File

@@ -7,13 +7,13 @@ Thanks for your interest in contributing! This guide explains how to get involve
1. Fork the repository and clone it locally 1. Fork the repository and clone it locally
2. Install dependencies with `npm install` 2. Install dependencies with `npm install`
3. Run `npm run dev` to start both client and server in development mode 3. Run `npm run dev` to start both client and server in development mode
4. Use the web UI at http://localhost:5173 to interact with the inspector 4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector
## Development Process & Pull Requests ## Development Process & Pull Requests
1. Create a new branch for your changes 1. Create a new branch for your changes
2. Make your changes following existing code style and conventions 2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable.
3. Test changes locally 3. Test changes locally by running `npm test`
4. Update documentation as needed 4. Update documentation as needed
5. Use clear commit messages explaining your changes 5. Use clear commit messages explaining your changes
6. Verify all changes work as expected 6. Verify all changes work as expected

View File

@@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/inde
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag 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: The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed:
```bash ```bash
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
@@ -42,6 +42,19 @@ For more details on ways to use the inspector, see the [Inspector section of the
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. 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.
### Security Considerations
The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.
### Configuration
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :
| Name | Purpose | Default Value |
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |
| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` |
### From this repository ### From this repository
If you're working on the inspector itself: If you're working on the inspector itself:

View File

@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
function delay(ms) { function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms, true));
} }
async function main() { async function main() {
@@ -61,8 +61,8 @@ async function main() {
"cli.js", "cli.js",
); );
const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173"; const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "3000"; const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector..."); console.log("Starting MCP inspector...");
@@ -73,42 +73,40 @@ async function main() {
cancelled = true; cancelled = true;
abort.abort(); abort.abort();
}); });
let server, serverOk;
const server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
const client = spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
// Make sure our server/client didn't immediately fail
await Promise.any([server, client, delay(2 * 1000)]);
const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`;
console.log(
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} 🚀`,
);
try { try {
await Promise.any([server, client]); server = spawnPromise(
} catch (e) { "node",
if (!cancelled || process.env.DEBUG) throw e; [
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
} }
return 0; return 0;

View File

@@ -15,5 +15,19 @@ const server = http.createServer((request, response) => {
}); });
}); });
const port = process.env.PORT || 5173; const port = process.env.PORT || 6274;
server.listen(port, () => {}); server.on("listening", () => {
console.log(
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
);
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port}`,
);
} else {
throw err;
}
});
server.listen(port);

View File

@@ -3,16 +3,12 @@ module.exports = {
testEnvironment: "jsdom", testEnvironment: "jsdom",
moduleNameMapper: { moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1", "^@/(.*)$": "<rootDir>/src/$1",
"^../components/DynamicJsonForm$": "\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
"^../../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
}, },
transform: { transform: {
"^.+\\.tsx?$": [ "^.+\\.tsx?$": [
"ts-jest", "ts-jest",
{ {
useESM: true,
jsx: "react-jsx", jsx: "react-jsx",
tsconfig: "tsconfig.jest.json", tsconfig: "tsconfig.jest.json",
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.7.0", "version": "0.8.1",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -18,31 +18,32 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview --port 6274",
"test": "jest --config jest.config.cjs", "test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch" "test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.8.0",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-toast": "^1.2.6",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"lucide-react": "^0.447.0", "lucide-react": "^0.447.0",
"pkce-challenge": "^4.1.0", "pkce-challenge": "^4.1.0",
"prismjs": "^1.29.0", "prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-toastify": "^10.0.6",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -50,6 +51,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.10", "@types/react": "^18.3.10",

View File

@@ -20,7 +20,6 @@ import {
import React, { Suspense, useEffect, useRef, useState } from "react"; import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection"; import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes"; import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -33,7 +32,6 @@ import {
MessageSquare, MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify";
import { z } from "zod"; import { z } from "zod";
import "./App.css"; import "./App.css";
import ConsoleTab from "./components/ConsoleTab"; import ConsoleTab from "./components/ConsoleTab";
@@ -45,23 +43,20 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab"; import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getMCPServerRequestTimeout,
} from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const App = () => { const App = () => {
const { toast } = useToast();
// Handle OAuth callback route // 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 [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[] ResourceTemplate[]
@@ -99,6 +94,17 @@ const App = () => {
>([]); >([]);
const [roots, setRoots] = useState<Root[]>([]); const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({}); const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
if (savedConfig) {
return {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
}
return DEFAULT_INSPECTOR_CONFIG;
});
const [bearerToken, setBearerToken] = useState<string>(() => { const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || ""; return localStorage.getItem("lastBearerToken") || "";
}); });
@@ -114,22 +120,6 @@ const App = () => {
const nextRequestId = useRef(0); const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]); const rootsRef = useRef<Root[]>([]);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
return prev.filter((r) => r.id !== id);
});
};
const handleRejectSampling = (id: number) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
return prev.filter((r) => r.id !== id);
});
};
const [selectedResource, setSelectedResource] = useState<Resource | null>( const [selectedResource, setSelectedResource] = useState<Resource | null>(
null, null,
); );
@@ -163,6 +153,7 @@ const App = () => {
handleCompletion, handleCompletion,
completionsSupported, completionsSupported,
connect: connectMcpServer, connect: connectMcpServer,
disconnect: disconnectMcpServer,
} = useConnection({ } = useConnection({
transportType, transportType,
command, command,
@@ -170,7 +161,8 @@ const App = () => {
sseUrl, sseUrl,
env, env,
bearerToken, bearerToken,
proxyServerUrl: PROXY_SERVER_URL, proxyServerUrl: getMCPProxyAddress(config),
requestTimeout: getMCPServerRequestTimeout(config),
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
}, },
@@ -209,8 +201,17 @@ const App = () => {
localStorage.setItem("lastBearerToken", bearerToken); localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]); }, [bearerToken]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
const hasProcessedRef = useRef(false);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => { useEffect(() => {
if (hasProcessedRef.current) {
// Only try to connect once
return;
}
const serverUrl = params.get("serverUrl"); const serverUrl = params.get("serverUrl");
if (serverUrl) { if (serverUrl) {
setSseUrl(serverUrl); setSseUrl(serverUrl);
@@ -220,14 +221,18 @@ const App = () => {
newUrl.searchParams.delete("serverUrl"); newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString()); window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth // Show success toast for OAuth
toast.success("Successfully authenticated with OAuth"); toast({
title: "Success",
description: "Successfully authenticated with OAuth",
});
hasProcessedRef.current = true;
// Connect to the server // Connect to the server
connectMcpServer(); connectMcpServer();
} }
}, []); }, [connectMcpServer, toast]);
useEffect(() => { useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`) fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
setEnv(data.defaultEnvironment); setEnv(data.defaultEnvironment);
@@ -241,6 +246,7 @@ const App = () => {
.catch((error) => .catch((error) =>
console.error("Error fetching default environment:", error), console.error("Error fetching default environment:", error),
); );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -253,6 +259,22 @@ const App = () => {
} }
}, []); }, []);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
return prev.filter((r) => r.id !== id);
});
};
const handleRejectSampling = (id: number) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
return prev.filter((r) => r.id !== id);
});
};
const clearError = (tabKey: keyof typeof errors) => { const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null })); setErrors((prev) => ({ ...prev, [tabKey]: null }));
}; };
@@ -425,6 +447,17 @@ const App = () => {
setLogLevel(level); setLogLevel(level);
}; };
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
@@ -439,9 +472,12 @@ const App = () => {
setSseUrl={setSseUrl} setSseUrl={setSseUrl}
env={env} env={env}
setEnv={setEnv} setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken} bearerToken={bearerToken}
setBearerToken={setBearerToken} setBearerToken={setBearerToken}
onConnect={connectMcpServer} onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}
logLevel={logLevel} logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest} sendLogLevelRequest={sendLogLevelRequest}

View File

@@ -0,0 +1 @@
module.exports = {};

View File

@@ -108,6 +108,21 @@ const DynamicJsonForm = ({
} }
}; };
const formatJson = () => {
try {
const jsonStr = rawJsonValue.trim();
if (!jsonStr) {
return;
}
const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2);
setRawJsonValue(formatted);
debouncedUpdateParent(formatted);
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const renderFormFields = ( const renderFormFields = (
propSchema: JsonSchemaType, propSchema: JsonSchemaType,
currentValue: JsonValue, currentValue: JsonValue,
@@ -353,7 +368,12 @@ const DynamicJsonForm = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end space-x-2">
{isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}>
Format JSON
</Button>
)}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}> <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"} {isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button> </Button>

View File

@@ -1,6 +1,6 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import JsonView from "./JsonView";
const HistoryAndNotifications = ({ const HistoryAndNotifications = ({
requestHistory, requestHistory,
@@ -24,10 +24,6 @@ const HistoryAndNotifications = ({
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] })); setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
}; };
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return ( return (
<div className="bg-card overflow-hidden flex h-full"> <div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r"> <div className="flex-1 overflow-y-auto p-4 border-r">
@@ -67,16 +63,12 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-blue-600"> <span className="font-semibold text-blue-600">
Request: Request:
</span> </span>
<button
onClick={() => copyToClipboard(request.request)}
className="text-blue-500 hover:text-blue-700"
>
<Copy size={16} />
</button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(JSON.parse(request.request), null, 2)} <JsonView
</pre> data={request.request}
className="bg-background"
/>
</div> </div>
{request.response && ( {request.response && (
<div className="mt-2"> <div className="mt-2">
@@ -84,20 +76,11 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-green-600"> <span className="font-semibold text-green-600">
Response: Response:
</span> </span>
<button
onClick={() => copyToClipboard(request.response!)}
className="text-blue-500 hover:text-blue-700"
>
<Copy size={16} />
</button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded"> <JsonView
{JSON.stringify( data={request.response}
JSON.parse(request.response), className="bg-background"
null, />
2,
)}
</pre>
</div> </div>
)} )}
</> </>
@@ -137,18 +120,11 @@ const HistoryAndNotifications = ({
<span className="font-semibold text-purple-600"> <span className="font-semibold text-purple-600">
Details: Details:
</span> </span>
<button
onClick={() =>
copyToClipboard(JSON.stringify(notification))
}
className="text-blue-500 hover:text-blue-700"
>
<Copy size={16} />
</button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded"> <JsonView
{JSON.stringify(notification, null, 2)} data={JSON.stringify(notification, null, 2)}
</pre> className="bg-background"
/>
</div> </div>
)} )}
</li> </li>

View File

@@ -3,7 +3,6 @@ import Editor from "react-simple-code-editor";
import Prism from "prismjs"; import Prism from "prismjs";
import "prismjs/components/prism-json"; import "prismjs/components/prism-json";
import "prismjs/themes/prism.css"; import "prismjs/themes/prism.css";
import { Button } from "@/components/ui/button";
interface JsonEditorProps { interface JsonEditorProps {
value: string; value: string;
@@ -16,49 +15,25 @@ const JsonEditor = ({
onChange, onChange,
error: externalError, error: externalError,
}: JsonEditorProps) => { }: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value); const [editorContent, setEditorContent] = useState(value || "");
const [internalError, setInternalError] = useState<string | undefined>( const [internalError, setInternalError] = useState<string | undefined>(
undefined, undefined,
); );
useEffect(() => { useEffect(() => {
setEditorContent(value); setEditorContent(value || "");
}, [value]); }, [value]);
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
const handleEditorChange = (newContent: string) => { const handleEditorChange = (newContent: string) => {
setEditorContent(newContent); setEditorContent(newContent);
setInternalError(undefined); setInternalError(undefined);
onChange(newContent); 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; const displayError = internalError || externalError;
return ( return (
<div className="relative space-y-2"> <div className="relative">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleFormatJson}>
Format JSON
</Button>
</div>
<div <div
className={`border rounded-md ${ className={`border rounded-md ${
displayError displayError

View File

@@ -0,0 +1,290 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import { JsonValue } from "./DynamicJsonForm";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
interface JsonViewProps {
data: unknown;
name?: string;
initialExpandDepth?: number;
className?: string;
withCopyButton?: boolean;
}
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
const JsonView = memo(
({
data,
name,
initialExpandDepth = 3,
className,
withCopyButton = true,
}: JsonViewProps) => {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 500);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const normalizedData = useMemo(() => {
return typeof data === "string"
? tryParseJson(data).success
? tryParseJson(data).data
: data
: data;
}, [data]);
const handleCopy = useCallback(() => {
try {
navigator.clipboard.writeText(
typeof normalizedData === "string"
? normalizedData
: JSON.stringify(normalizedData, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}, [toast, normalizedData]);
return (
<div className={clsx("p-4 border rounded relative", className)}>
{withCopyButton && (
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleCopy}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
)}
<div className="font-mono text-sm transition-all duration-300">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
/>
</div>
</div>
);
},
);
JsonView.displayName = "JsonView";
interface JsonNodeProps {
data: JsonValue;
name?: string;
depth: number;
initialExpandDepth: number;
}
const JsonNode = memo(
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const getDataType = (value: JsonValue): string => {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
};
const dataType = getDataType(data);
const typeStyleMap: Record<string, string> = {
number: "text-blue-600",
boolean: "text-amber-600",
null: "text-purple-600",
undefined: "text-gray-600",
string: "text-green-600 break-all whitespace-pre-wrap",
default: "text-gray-700",
};
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
: Object.entries(data as Record<string, JsonValue>);
const itemCount = items.length;
const isEmpty = itemCount === 0;
const symbolMap = {
open: isArray ? "[" : "{",
close: isArray ? "]" : "}",
collapsed: isArray ? "[ ... ]" : "{ ... }",
empty: isArray ? "[]" : "{}",
};
if (isEmpty) {
return (
<div className="flex items-center">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className="text-gray-500">{symbolMap.empty}</span>
</div>
);
}
return (
<div className="flex flex-col">
<div
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
onClick={() => setIsExpanded(!isExpanded)}
>
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
{isExpanded ? (
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.open}
</span>
) : (
<>
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.collapsed}
</span>
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</>
)}
</div>
{isExpanded && (
<>
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
{isArray
? (items as JsonValue[]).map((item, index) => (
<div key={index} className="my-1">
<JsonNode
data={item}
name={`${index}`}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))
: (items as [string, JsonValue][]).map(([key, value]) => (
<div key={key} className="my-1">
<JsonNode
data={value}
name={key}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))}
</div>
<div className="text-gray-600 dark:text-gray-400">
{symbolMap.close}
</div>
</>
)}
</div>
);
};
const renderString = (value: string) => {
const maxLength = 100;
const isTooLong = value.length > maxLength;
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<pre className={typeStyleMap.string}>"{value}"</pre>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
<pre
className={clsx(
typeStyleMap.string,
"cursor-pointer group-hover:text-green-500",
)}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</pre>
</div>
);
};
switch (dataType) {
case "object":
case "array":
return renderCollapsible(dataType === "array");
case "string":
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
</div>
);
}
},
);
JsonNode.displayName = "JsonNode";
export default JsonView;

View File

@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { import {
ListPromptsResult, ListPromptsResult,
PromptReference, PromptReference,
@@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState"; import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
export type Prompt = { export type Prompt = {
name: string; name: string;
@@ -151,11 +152,7 @@ const PromptsTab = ({
Get Prompt Get Prompt
</Button> </Button>
{promptContent && ( {promptContent && (
<Textarea <JsonView data={promptContent} withCopyButton={false} />
value={promptContent}
readOnly
className="h-64 font-mono"
/>
)} )}
</div> </div>
) : ( ) : (

View File

@@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState"; import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
const ResourcesTab = ({ const ResourcesTab = ({
resources, resources,
@@ -214,9 +215,10 @@ const ResourcesTab = ({
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
) : selectedResource ? ( ) : selectedResource ? (
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100"> <JsonView
{resourceContent} data={resourceContent}
</pre> className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
/>
) : selectedTemplate ? ( ) : selectedTemplate ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">

View File

@@ -5,6 +5,7 @@ import {
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import JsonView from "./JsonView";
export type PendingRequest = { export type PendingRequest = {
id: number; id: number;
@@ -43,9 +44,11 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<h3 className="text-lg font-semibold">Recent Requests</h3> <h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4"> <div key={request.id} className="p-4 border rounded-lg space-y-4">
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded"> <JsonView
{JSON.stringify(request.request, null, 2)} className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
</pre> data={JSON.stringify(request.request)}
/>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button> <Button onClick={() => handleApprove(request.id)}>Approve</Button>
<Button variant="outline" onClick={() => onReject(request.id)}> <Button variant="outline" onClick={() => onReject(request.id)}>

View File

@@ -8,6 +8,10 @@ import {
Github, Github,
Eye, Eye,
EyeOff, EyeOff,
RotateCcw,
Settings,
HelpCircle,
RefreshCwOff,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -23,12 +27,18 @@ import {
LoggingLevel, LoggingLevel,
LoggingLevelSchema, LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/useTheme"; import useTheme from "../lib/useTheme";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
interface SidebarProps { interface SidebarProps {
connectionStatus: "disconnected" | "connected" | "error"; connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse"; transportType: "stdio" | "sse";
setTransportType: (type: "stdio" | "sse") => void; setTransportType: (type: "stdio" | "sse") => void;
command: string; command: string;
@@ -42,10 +52,13 @@ interface SidebarProps {
bearerToken: string; bearerToken: string;
setBearerToken: (token: string) => void; setBearerToken: (token: string) => void;
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel; logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void; sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean; loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
} }
const Sidebar = ({ const Sidebar = ({
@@ -63,14 +76,18 @@ const Sidebar = ({
bearerToken, bearerToken,
setBearerToken, setBearerToken,
onConnect, onConnect,
onDisconnect,
stdErrNotifications, stdErrNotifications,
logLevel, logLevel,
sendLogLevelRequest, sendLogLevelRequest,
loggingSupported, loggingSupported,
config,
setConfig,
}: SidebarProps) => { }: SidebarProps) => {
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false); const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false); const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set()); const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
return ( return (
@@ -169,6 +186,7 @@ const Sidebar = ({
variant="outline" variant="outline"
onClick={() => setShowEnvVars(!showEnvVars)} onClick={() => setShowEnvVars(!showEnvVars)}
className="flex items-center w-full" className="flex items-center w-full"
data-testid="env-vars-button"
> >
{showEnvVars ? ( {showEnvVars ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -284,28 +302,147 @@ const Sidebar = ({
</div> </div>
)} )}
{/* Configuration */}
<div className="space-y-2"> <div className="space-y-2">
<Button className="w-full" onClick={onConnect}> <Button
<Play className="w-4 h-4 mr-2" /> variant="outline"
Connect onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
data-testid="config-button"
>
{showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
<Settings className="w-4 h-4 mr-2" />
Configuration
</Button> </Button>
{showConfig && (
<div className="space-y-2">
{Object.entries(config).map(([key, configItem]) => {
const configKey = key as keyof InspectorConfig;
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-green-600">
{configKey}
</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
{configItem.description}
</TooltipContent>
</Tooltip>
</div>
{typeof configItem.value === "number" ? (
<Input
type="number"
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: Number(e.target.value),
};
setConfig(newConfig);
}}
className="font-mono"
/>
) : typeof configItem.value === "boolean" ? (
<Select
data-testid={`${configKey}-select`}
value={configItem.value.toString()}
onValueChange={(val) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: val === "true",
};
setConfig(newConfig);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: e.target.value,
};
setConfig(newConfig);
}}
className="font-mono"
/>
)}
</div>
);
})}
</div>
)}
</div>
<div className="space-y-2">
{connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4">
<Button data-testid="connect-button" onClick={onConnect}>
<RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"}
</Button>
<Button onClick={onDisconnect}>
<RefreshCwOff className="w-4 h-4 mr-2" />
Disconnect
</Button>
</div>
)}
{connectionStatus !== "connected" && (
<Button className="w-full" onClick={onConnect}>
<Play className="w-4 h-4 mr-2" />
Connect
</Button>
)}
<div className="flex items-center justify-center space-x-2 mb-4"> <div className="flex items-center justify-center space-x-2 mb-4">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${(() => {
connectionStatus === "connected" switch (connectionStatus) {
? "bg-green-500" case "connected":
: connectionStatus === "error" return "bg-green-500";
? "bg-red-500" case "error":
: "bg-gray-500" return "bg-red-500";
}`} case "error-connecting-to-proxy":
return "bg-red-500";
default:
return "bg-gray-500";
}
})()}`}
/> />
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{connectionStatus === "connected" {(() => {
? "Connected" switch (connectionStatus) {
: connectionStatus === "error" case "connected":
? "Connection Error" return "Connected";
: "Disconnected"} case "error":
return "Connection Error, is your MCP server running?";
case "error-connecting-to-proxy":
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
default:
return "Disconnected";
}
})()}
</span> </span>
</div> </div>
@@ -371,36 +508,37 @@ const Sidebar = ({
</Select> </Select>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<a <Button variant="ghost" title="Inspector Documentation" asChild>
href="https://modelcontextprotocol.io/docs/tools/inspector" <a
target="_blank" href="https://modelcontextprotocol.io/docs/tools/inspector"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
<Button variant="ghost" title="Inspector Documentation">
<CircleHelp className="w-4 h-4 text-gray-800" />
</Button>
</a>
<a
href="https://modelcontextprotocol.io/docs/tools/debugging"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" title="Debugging Guide">
<Bug className="w-4 h-4 text-gray-800" />
</Button>
</a>
<a
href="https://github.com/modelcontextprotocol/inspector"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="ghost"
title="Report bugs or contribute on GitHub"
> >
<Github className="w-4 h-4 text-gray-800" /> <CircleHelp className="w-4 h-4 text-foreground" />
</Button> </a>
</a> </Button>
<Button variant="ghost" title="Debugging Guide" asChild>
<a
href="https://modelcontextprotocol.io/docs/tools/debugging"
target="_blank"
rel="noopener noreferrer"
>
<Bug className="w-4 h-4 text-foreground" />
</a>
</Button>
<Button
variant="ghost"
title="Report bugs or contribute on GitHub"
asChild
>
<a
href="https://github.com/modelcontextprotocol/inspector"
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-4 h-4 text-foreground" />
</a>
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,10 +13,10 @@ import {
ListToolsResult, ListToolsResult,
Tool, Tool,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react"; import { Send } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { escapeUnicode } from "@/utils/escapeUnicode"; import JsonView from "./JsonView";
const ToolsTab = ({ const ToolsTab = ({
tools, tools,
@@ -27,7 +27,6 @@ const ToolsTab = ({
setSelectedTool, setSelectedTool,
toolResult, toolResult,
nextCursor, nextCursor,
error,
}: { }: {
tools: Tool[]; tools: Tool[];
listTools: () => void; listTools: () => void;
@@ -53,17 +52,10 @@ const ToolsTab = ({
return ( return (
<> <>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4> <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"> <JsonView data={toolResult} />
{escapeUnicode(toolResult)}
</pre>
<h4 className="font-semibold mb-2">Errors:</h4> <h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => ( {parsedResult.error.errors.map((error, idx) => (
<pre <JsonView data={error} key={idx} />
key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
>
{escapeUnicode(error)}
</pre>
))} ))}
</> </>
); );
@@ -78,11 +70,7 @@ const ToolsTab = ({
</h4> </h4>
{structuredResult.content.map((item, index) => ( {structuredResult.content.map((item, index) => (
<div key={index} className="mb-2"> <div key={index} className="mb-2">
{item.type === "text" && ( {item.type === "text" && <JsonView data={item.text} />}
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{item.text}
</pre>
)}
{item.type === "image" && ( {item.type === "image" && (
<img <img
src={`data:${item.mimeType};base64,${item.data}`} src={`data:${item.mimeType};base64,${item.data}`}
@@ -100,9 +88,7 @@ const ToolsTab = ({
<p>Your browser does not support audio playback</p> <p>Your browser does not support audio playback</p>
</audio> </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"> <JsonView data={item.resource} />
{escapeUnicode(item.resource)}
</pre>
))} ))}
</div> </div>
))} ))}
@@ -112,9 +98,8 @@ const ToolsTab = ({
return ( return (
<> <>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4> <h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(toolResult.toolResult)} <JsonView data={toolResult.toolResult} />
</pre>
</> </>
); );
} }
@@ -150,13 +135,7 @@ const ToolsTab = ({
</h3> </h3>
</div> </div>
<div className="p-4"> <div className="p-4">
{error ? ( {selectedTool ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedTool ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{selectedTool.description} {selectedTool.description}
@@ -229,7 +208,11 @@ const ToolsTab = ({
</div> </div>
) : ( ) : (
<Input <Input
type={prop.type === "number" ? "number" : "text"} type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key} id={key}
name={key} name={key}
placeholder={prop.description} placeholder={prop.description}
@@ -238,7 +221,8 @@ const ToolsTab = ({
setParams({ setParams({
...params, ...params,
[key]: [key]:
prop.type === "number" prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value) ? Number(e.target.value)
: e.target.value, : e.target.value,
}) })

View File

@@ -0,0 +1,95 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "../DynamicJsonForm";
describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "string" as const,
description: "Test string field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Type Validation", () => {
it("should handle numeric input as string type", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "123321" } });
expect(onChange).toHaveBeenCalledWith("123321");
// Verify the value is a string, not a number
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("should render as text input, not number input", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
});
});
describe("DynamicJsonForm Integer Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "integer" as const,
description: "Test integer field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render number input with step=1", () => {
renderForm();
const input = screen.getByRole("spinbutton");
expect(input).toHaveProperty("type", "number");
expect(input).toHaveProperty("step", "1");
});
it("should pass integer values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "42" } });
expect(onChange).toHaveBeenCalledWith(42);
// Verify the value is a number, not a string
expect(typeof onChange.mock.calls[0][0]).toBe("number");
});
it("should not pass string values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle non-numeric input by not calling onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,419 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook
jest.mock("../../lib/useTheme", () => ({
__esModule: true,
default: () => ["light", jest.fn()],
}));
describe("Sidebar Environment Variables", () => {
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
setTransportType: jest.fn(),
command: "",
setCommand: jest.fn(),
args: "",
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
const renderSidebar = (props = {}) => {
return render(
<TooltipProvider>
<Sidebar {...defaultProps} {...props} />
</TooltipProvider>,
);
};
const openEnvVarsSection = () => {
const button = screen.getByTestId("env-vars-button");
fireEvent.click(button);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Basic Operations", () => {
it("should add a new environment variable", () => {
const setEnv = jest.fn();
renderSidebar({ env: {}, setEnv });
openEnvVarsSection();
const addButton = screen.getByText("Add Environment Variable");
fireEvent.click(addButton);
expect(setEnv).toHaveBeenCalledWith({ "": "" });
});
it("should remove an environment variable", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const removeButton = screen.getByRole("button", { name: "×" });
fireEvent.click(removeButton);
expect(setEnv).toHaveBeenCalledWith({});
});
it("should update environment variable value", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
fireEvent.change(valueInput, { target: { value: "new_value" } });
expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" });
});
it("should toggle value visibility", () => {
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "password");
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
expect(valueInput).toHaveProperty("type", "text");
});
});
describe("Key Editing", () => {
it("should maintain order when editing first key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
NEW_FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing middle key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing last key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const lastKeyInput = screen.getByDisplayValue("THIRD_KEY");
fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
NEW_THIRD_KEY: "third_value",
});
});
it("should maintain order during key editing", () => {
const setEnv = jest.fn();
const initialEnv = {
KEY1: "value1",
KEY2: "value2",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// Type "NEW_" one character at a time
const key1Input = screen.getByDisplayValue("KEY1");
"NEW_".split("").forEach((char) => {
fireEvent.change(key1Input, {
target: { value: char + "KEY1".slice(1) },
});
});
// Verify the last setEnv call maintains the order
const lastCall = setEnv.mock.calls[
setEnv.mock.calls.length - 1
][0] as Record<string, string>;
const entries = Object.entries(lastCall);
// The values should stay with their original keys
expect(entries[0][1]).toBe("value1"); // First entry should still have value1
expect(entries[1][1]).toBe("value2"); // Second entry should still have value2
});
});
describe("Multiple Operations", () => {
it("should maintain state after multiple key edits", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
};
const { rerender } = renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// First key edit
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
// Get the updated env from the first setEnv call
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
// Rerender with the updated env
rerender(
<TooltipProvider>
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
</TooltipProvider>,
);
// Second key edit
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
// Verify the final state matches what we expect
expect(setEnv).toHaveBeenLastCalledWith({
NEW_FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
});
});
it("should maintain visibility state after key edit", () => {
const initialEnv = { TEST_KEY: "test_value" };
const { rerender } = renderSidebar({ env: initialEnv });
openEnvVarsSection();
// Show the value
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "text");
// Edit the key
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
// Rerender with updated env
rerender(
<TooltipProvider>
<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />
</TooltipProvider>,
);
// Value should still be visible
const updatedValueInput = screen.getByDisplayValue("test_value");
expect(updatedValueInput).toHaveProperty("type", "text");
});
});
describe("Edge Cases", () => {
it("should handle empty key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "" } });
expect(setEnv).toHaveBeenCalledWith({ "": "test_value" });
});
it("should handle special characters in key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
});
it("should handle unicode characters", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST_🔑" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
});
it("should handle very long key names", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
const longKey = "A".repeat(100);
fireEvent.change(keyInput, { target: { value: longKey } });
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
});
});
describe("Configuration Operations", () => {
const openConfigSection = () => {
const button = screen.getByTestId("config-button");
fireEvent.click(button);
};
it("should update MCP server request timeout", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "5000" } });
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
},
}),
);
});
it("should handle invalid timeout values entered by user", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
}),
);
});
it("should maintain configuration state after multiple updates", () => {
const setConfig = jest.fn();
const { rerender } = renderSidebar({
config: DEFAULT_INSPECTOR_CONFIG,
setConfig,
});
openConfigSection();
// First update
const timeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(timeoutInput, { target: { value: "5000" } });
// Get the updated config from the first setConfig call
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
// Rerender with the updated config
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>
</TooltipProvider>,
);
// Second update
const updatedTimeoutInput = screen.getByTestId(
"MCP_SERVER_REQUEST_TIMEOUT-input",
);
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
// Verify the final state matches what we expect
expect(setConfig).toHaveBeenLastCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},
}),
);
});
});
});

View File

@@ -0,0 +1,102 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
describe("ToolsTab", () => {
const mockTools: Tool[] = [
{
name: "tool1",
description: "First tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
{
name: "tool3",
description: "Integer tool",
inputSchema: {
type: "object" as const,
properties: {
count: { type: "integer" as const },
},
},
},
{
name: "tool2",
description: "Second tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
];
const defaultProps = {
tools: mockTools,
listTools: jest.fn(),
clearTools: jest.fn(),
callTool: jest.fn(),
selectedTool: null,
setSelectedTool: jest.fn(),
toolResult: null,
nextCursor: "",
error: null,
};
const renderToolsTab = (props = {}) => {
return render(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} {...props} />
</Tabs>,
);
};
it("should reset input values when switching tools", () => {
const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
});
// Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } });
expect(input.value).toBe("42");
// Switch to second tool
rerender(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} selectedTool={mockTools[2]} />
</Tabs>,
);
// Verify input is reset
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe("");
});
it("should handle integer type inputs", () => {
renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type
});
const input = screen.getByRole("spinbutton", {
name: /count/i,
}) as HTMLInputElement;
expect(input).toHaveProperty("type", "number");
fireEvent.change(input, { target: { value: "42" } });
expect(input.value).toBe("42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
fireEvent.click(submitButton);
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
});
});

View File

@@ -22,7 +22,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 hover:border-[#646cff] hover:border-1",
className, className,
)} )}
{...props} {...props}

View File

@@ -0,0 +1,126 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,191 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -38,29 +38,6 @@ h1 {
line-height: 1.1; line-height: 1.1;
} }
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
button[role="checkbox"] {
padding: 0;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;
@@ -69,9 +46,6 @@ button[role="checkbox"] {
a:hover { a:hover {
color: #747bff; color: #747bff;
} }
button {
background-color: #f9f9f9;
}
} }
@layer base { @layer base {

View File

@@ -0,0 +1,19 @@
export type ConfigItem = {
description: string;
value: string | number | boolean;
};
/**
* Configuration interface for the MCP Inspector, including settings for the MCP Client,
* Proxy Server, and Inspector UI/UX.
*
* Note: Configuration related to which MCP Server to use or any other MCP Server
* specific settings are outside the scope of this interface as of now.
*/
export type InspectorConfig = {
/**
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
MCP_PROXY_FULL_ADDRESS: ConfigItem;
};

View File

@@ -1,3 +1,5 @@
import { InspectorConfig } from "./configurationTypes";
// OAuth-related session storage keys // OAuth-related session storage keys
export const SESSION_KEYS = { export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier", CODE_VERIFIER: "mcp_code_verifier",
@@ -5,3 +7,27 @@ export const SESSION_KEYS = {
TOKENS: "mcp_tokens", TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information", CLIENT_INFORMATION: "mcp_client_information",
} as const; } as const;
export type ConnectionStatus =
| "disconnected"
| "connected"
| "error"
| "error-connecting-to-proxy";
export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
/**
* Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser.
* Future plans: Provide json config file + Browser local_storage to override default values
**/
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
},
MCP_PROXY_FULL_ADDRESS: {
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "",
},
} as const;

View File

@@ -19,20 +19,20 @@ import {
McpError, McpError,
CompleteResultSchema, CompleteResultSchema,
ErrorCode, ErrorCode,
CancelledNotificationSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify"; import { useToast } from "@/hooks/use-toast";
import { z } from "zod"; import { z } from "zod";
import { SESSION_KEYS } from "../constants"; import { ConnectionStatus, SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth"; import { authProvider } from "../auth";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
const params = new URLSearchParams(window.location.search);
const DEFAULT_REQUEST_TIMEOUT_MSEC =
parseInt(params.get("timeout") ?? "") || 10000;
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse";
command: string; command: string;
@@ -44,7 +44,9 @@ interface UseConnectionOptions {
requestTimeout?: number; requestTimeout?: number;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void; onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[]; getRoots?: () => any[];
} }
@@ -62,15 +64,15 @@ export function useConnection({
env, env,
proxyServerUrl, proxyServerUrl,
bearerToken, bearerToken,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC, requestTimeout,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
onPendingRequest, onPendingRequest,
getRoots, getRoots,
}: UseConnectionOptions) { }: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] = useState< const [connectionStatus, setConnectionStatus] =
"disconnected" | "connected" | "error" useState<ConnectionStatus>("disconnected");
>("disconnected"); const { toast } = useToast();
const [serverCapabilities, setServerCapabilities] = const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null); useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null); const [mcpClient, setMcpClient] = useState<Client | null>(null);
@@ -123,7 +125,11 @@ export function useConnection({
} catch (e: unknown) { } catch (e: unknown) {
if (!options?.suppressToast) { if (!options?.suppressToast) {
const errorString = (e as Error).message ?? String(e); const errorString = (e as Error).message ?? String(e);
toast.error(errorString); toast({
title: "Error",
description: errorString,
variant: "destructive",
});
} }
throw e; throw e;
} }
@@ -165,7 +171,11 @@ export function useConnection({
} }
// Unexpected errors - show toast and rethrow // Unexpected errors - show toast and rethrow
toast.error(e instanceof Error ? e.message : String(e)); toast({
title: "Error",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
throw e; throw e;
} }
}; };
@@ -173,7 +183,11 @@ export function useConnection({
const sendNotification = async (notification: ClientNotification) => { const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) { if (!mcpClient) {
const error = new Error("MCP client not connected"); const error = new Error("MCP client not connected");
toast.error(error.message); toast({
title: "Error",
description: error.message,
variant: "destructive",
});
throw error; throw error;
} }
@@ -186,7 +200,25 @@ export function useConnection({
// Log MCP protocol errors // Log MCP protocol errors
pushHistory(notification, { error: e.message }); pushHistory(notification, { error: e.message });
} }
toast.error(e instanceof Error ? e.message : String(e)); toast({
title: "Error",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
throw e;
}
};
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl);
const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") {
throw new Error("MCP Proxy Server is not healthy");
}
} catch (e) {
console.error("Couldn't connect to MCP Proxy Server", e);
throw e; throw e;
} }
}; };
@@ -203,33 +235,38 @@ export function useConnection({
}; };
const connect = async (_e?: unknown, retryCount: number = 0) => { const connect = async (_e?: unknown, retryCount: number = 0) => {
try { const client = new Client<Request, Notification, Result>(
const client = new Client<Request, Notification, Result>( {
{ name: "mcp-inspector",
name: "mcp-inspector", version: packageJson.version,
version: packageJson.version, },
}, {
{ capabilities: {
capabilities: { sampling: {},
sampling: {}, roots: {
roots: { listChanged: true,
listChanged: true,
},
}, },
}, },
); },
);
const backendUrl = new URL(`${proxyServerUrl}/sse`); try {
await checkProxyHealth();
backendUrl.searchParams.append("transportType", transportType); } catch {
if (transportType === "stdio") { setConnectionStatus("error-connecting-to-proxy");
backendUrl.searchParams.append("command", command); return;
backendUrl.searchParams.append("args", args); }
backendUrl.searchParams.append("env", JSON.stringify(env)); const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
} else { mcpProxyServerUrl.searchParams.append("transportType", transportType);
backendUrl.searchParams.append("url", sseUrl); if (transportType === "stdio") {
} mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
} else {
mcpProxyServerUrl.searchParams.append("url", sseUrl);
}
try {
// Inject auth manually instead of using SSEClientTransport, because we're // Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first. // proxying through the inspector server first.
const headers: HeadersInit = {}; const headers: HeadersInit = {};
@@ -240,7 +277,7 @@ export function useConnection({
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const clientTransport = new SSEClientTransport(backendUrl, { const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
eventSourceInit: { eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }), fetch: (url, init) => fetch(url, { ...init, headers }),
}, },
@@ -250,20 +287,24 @@ export function useConnection({
}); });
if (onNotification) { if (onNotification) {
client.setNotificationHandler( [
CancelledNotificationSchema,
ProgressNotificationSchema, ProgressNotificationSchema,
onNotification,
);
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
onNotification,
);
client.setNotificationHandler(
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
onNotification, ResourceUpdatedNotificationSchema,
); ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
].forEach((notificationSchema) => {
client.setNotificationHandler(notificationSchema, onNotification);
});
client.fallbackNotificationHandler = (
notification: Notification,
): Promise<void> => {
onNotification(notification);
return Promise.resolve();
};
} }
if (onStdErrNotification) { if (onStdErrNotification) {
@@ -276,7 +317,10 @@ export function useConnection({
try { try {
await client.connect(clientTransport); await client.connect(clientTransport);
} catch (error) { } catch (error) {
console.error("Failed to connect to MCP server:", error); console.error(
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
error,
);
const shouldRetry = await handleAuthError(error); const shouldRetry = await handleAuthError(error);
if (shouldRetry) { if (shouldRetry) {
return connect(undefined, retryCount + 1); return connect(undefined, retryCount + 1);
@@ -315,6 +359,14 @@ export function useConnection({
} }
}; };
const disconnect = async () => {
await mcpClient?.close();
setMcpClient(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);
};
return { return {
connectionStatus, connectionStatus,
serverCapabilities, serverCapabilities,
@@ -325,5 +377,6 @@ export function useConnection({
handleCompletion, handleCompletion,
completionsSupported, completionsSupported,
connect, connect,
disconnect,
}; };
} }

View File

@@ -14,7 +14,9 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
export const NotificationSchema = ClientNotificationSchema.or( export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema, StdErrNotificationSchema,
).or(ServerNotificationSchema); )
.or(ServerNotificationSchema)
.or(BaseNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>; export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>; export type Notification = z.infer<typeof NotificationSchema>;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system"; type Theme = "light" | "dark" | "system";
@@ -36,16 +36,14 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
}; };
}, [theme]); }, [theme]);
return [ const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
theme, setTheme(newTheme);
useCallback((newTheme: Theme) => { localStorage.setItem("theme", newTheme);
setTheme(newTheme); if (newTheme !== "system") {
localStorage.setItem("theme", newTheme); document.documentElement.classList.toggle("dark", newTheme === "dark");
if (newTheme !== "system") { }
document.documentElement.classList.toggle("dark", newTheme === "dark"); }, []);
} return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
}, []),
];
}; };
export default useTheme; export default useTheme;

View File

@@ -1,13 +1,15 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ToastContainer } from "react-toastify"; import { Toaster } from "@/components/ui/toaster.tsx";
import "react-toastify/dist/ReactToastify.css";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
import { TooltipProvider } from "./components/ui/tooltip.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <TooltipProvider>
<ToastContainer /> <App />
</TooltipProvider>
<Toaster />
</StrictMode>, </StrictMode>,
); );

View File

@@ -0,0 +1,14 @@
import { InspectorConfig } from "@/lib/configurationTypes";
import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "@/lib/constants";
export const getMCPProxyAddress = (config: InspectorConfig): string => {
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
if (proxyFullAddress) {
return proxyFullAddress;
}
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
};
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
};

View File

@@ -24,7 +24,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"resolveJsonModule": true "resolveJsonModule": true,
"types": ["jest", "@testing-library/jest-dom", "node"]
}, },
"include": ["src"] "include": ["src"]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 385 KiB

3221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.7.0", "version": "0.8.1",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -23,6 +23,7 @@
"scripts": { "scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"", "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", "dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
"test": "npm run prettier-check && cd client && npm test",
"build-server": "cd server && npm run build", "build-server": "cd server && npm run build",
"build-client": "cd client && npm run build", "build-client": "cd client && npm run build",
"build": "npm run build-server && npm run build-client", "build": "npm run build-server && npm run build-client",
@@ -31,11 +32,12 @@
"start": "node ./bin/cli.js", "start": "node ./bin/cli.js",
"prepare": "npm run build", "prepare": "npm run build",
"prettier-fix": "prettier --write .", "prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .",
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-client": "^0.7.0", "@modelcontextprotocol/inspector-client": "^0.8.1",
"@modelcontextprotocol/inspector-server": "^0.7.0", "@modelcontextprotocol/inspector-server": "^0.8.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.7.0", "version": "0.8.1",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0", "express": "^4.21.0",
"ws": "^8.18.0", "ws": "^8.18.0",

View File

@@ -12,6 +12,7 @@ import {
StdioClientTransport, StdioClientTransport,
getDefaultEnvironment, getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js"; } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express"; import express from "express";
import { findActualExecutable } from "spawn-rx"; import { findActualExecutable } from "spawn-rx";
@@ -37,7 +38,7 @@ app.use(cors());
let webAppTransports: SSEServerTransport[] = []; let webAppTransports: SSEServerTransport[] = [];
const createTransport = async (req: express.Request) => { const createTransport = async (req: express.Request): Promise<Transport> => {
const query = req.query; const query = req.query;
console.log("Query parameters:", query); console.log("Query parameters:", query);
@@ -69,6 +70,7 @@ const createTransport = async (req: express.Request) => {
const headers: HeadersInit = { const headers: HeadersInit = {
Accept: "text/event-stream", Accept: "text/event-stream",
}; };
for (const key of SSE_HEADERS_PASSTHROUGH) { for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) { if (req.headers[key] === undefined) {
continue; continue;
@@ -98,12 +100,14 @@ const createTransport = async (req: express.Request) => {
} }
}; };
let backingServerTransport: Transport | undefined;
app.get("/sse", async (req, res) => { app.get("/sse", async (req, res) => {
try { try {
console.log("New SSE connection"); console.log("New SSE connection");
let backingServerTransport;
try { try {
await backingServerTransport?.close();
backingServerTransport = await createTransport(req); backingServerTransport = await createTransport(req);
} catch (error) { } catch (error) {
if (error instanceof SseError && error.code === 401) { if (error instanceof SseError && error.code === 401) {
@@ -169,6 +173,12 @@ app.post("/message", async (req, res) => {
} }
}); });
app.get("/health", (req, res) => {
res.json({
status: "ok",
});
});
app.get("/config", (req, res) => { app.get("/config", (req, res) => {
try { try {
res.json({ res.json({
@@ -182,17 +192,17 @@ app.get("/config", (req, res) => {
} }
}); });
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 6277;
try { const server = app.listen(PORT);
const server = app.listen(PORT); server.on("listening", () => {
console.log(`⚙️ Proxy server listening on port ${PORT}`);
server.on("listening", () => { });
const addr = server.address(); server.on("error", (err) => {
const port = typeof addr === "string" ? addr : addr?.port; if (err.message.includes(`EADDRINUSE`)) {
console.log(`Proxy server listening on port ${port}`); console.error(`Proxy Server PORT IS IN USE at port ${PORT}`);
}); } else {
} catch (error) { console.error(err.message);
console.error("Failed to start server:", error); }
process.exit(1); process.exit(1);
} });