Compare commits

...

389 Commits
0.3.0 ... 0.8.0

Author SHA1 Message Date
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
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
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
Ola Hungerford
c031831e71 Merge pull request #213 from olaservo/release/0.7.0
Bump version to 0.7.0
2025-03-25 03:40:18 -07: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
16b38071e7 Bump version to 0.7.0 2025-03-24 12:36:17 -07:00
Ola Hungerford
25f5bb7620 Merge branch 'main' into add-ui-tests 2025-03-24 12:01:41 -07:00
Ola Hungerford
731cee8511 Merge pull request #206 from markacianfrani/main
fix(sidebar): maintain order when changing values
2025-03-24 12:01:12 -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
Mark Anthony Cianfrani
30e7a4d7b7 Merge branch 'main' into main 2025-03-24 12:59:13 -04:00
cgoing
d204dd6e7e feat: json view component - dark color 2025-03-25 01:56:53 +09:00
Mark Anthony Cianfrani
f2f209dbd3 fix(sidebar): maintain order when changing values 2025-03-24 12:56:41 -04: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
f8b7b88a25 Merge pull request #198 from nathanArseneau/error-when-switching-Tools-without-changing-value
fix: set default value for input fields in ToolsTab component
2025-03-24 09:29:05 -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
Ola Hungerford
2890e036ed Merge pull request #201 from Larmyliu/feat/proxyServerUrl
Update Vite configuration to enable host access and use `window.location.hostname` for Inspector URL
2025-03-22 21:13:39 -07:00
Ola Hungerford
008204fda5 Merge pull request #193 from seuros/main
feat: Add utility function to escape Unicode characters in tool results
2025-03-22 19:14:16 -07:00
Abdelkader Boudih
af44efb236 chore: extract utils escapeUnicode 2025-03-22 16:25:33 +00:00
Larmyliu
51f3135c76 Merge branch 'main' into feat/proxyServerUrl 2025-03-23 00:11:58 +08:00
Pulkit Sharma
4a23585066 Add support in UI to configure request timeout 2025-03-22 21:18:38 +05:30
Abdelkader Boudih
3488bdb613 feat: Add utility function to escape Unicode characters in tool results 2025-03-21 22:21:26 +00:00
Ola Hungerford
043f6040c6 Merge pull request #174 from ryanrozich/fix-env-var-parsing
Fix environment variable parsing to handle values with equals signs
2025-03-21 09:01:09 -07:00
Ola Hungerford
74d0fcf5a3 Merge branch 'main' into fix-env-var-parsing 2025-03-21 08:39:32 -07:00
Ola Hungerford
de8795106c Merge pull request #189 from cliffhall/add-log-level-setting
Add log level setting in UI
2025-03-21 08:38:58 -07:00
Cliff Hall
c726c53b00 Merge branch 'main' into add-log-level-setting 2025-03-21 11:06:31 -04:00
Ola Hungerford
20db043b40 Merge branch 'main' into fix-env-var-parsing 2025-03-21 07:07:04 -07:00
Ola Hungerford
dcbd1dad41 Bump prismjs from 1.29.0 to 1.30.0 to address 2025-03-21 06:54:46 -07:00
Ola Hungerford
dfc9cf7629 Merge pull request #159 from olaservo/handle-empty-json-fields
Improve on tool input handling and add tests
2025-03-21 06:36:44 -07:00
jazminliu
4fdbcee706 Update Vite configuration to enable host access and fix proxy server URL to use the current hostname. 2025-03-20 22:04:59 +08:00
Shinya Fujino
ce81fb976b Restructure link buttons in sidebar to respect theme 2025-03-20 22:18:44 +09:00
Nathan Arseneau
029e482e05 fix: set default value for input fields in ToolsTab component 2025-03-19 20:15:14 -04: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
Ola Hungerford
c463dc58c2 Simplify check for defaults and add another test 2025-03-18 06:23:25 -07:00
Ola Hungerford
a85d5e7050 Fix formatting 2025-03-17 07:56:23 -07:00
Ola Hungerford
7ddba51b36 Generate empty objects and arrays for non required object and array fields 2025-03-17 06:36:35 -07:00
Nathan Arseneau
dd460bd877 Refactor notification handling to include all notifications 2025-03-17 02:44:59 -04:00
Ola Hungerford
50131c6960 Merge branch 'main' into handle-empty-json-fields 2025-03-16 15:56:24 -07:00
Ola Hungerford
28978ea24f Update package lock after re-running npm install 2025-03-16 15:42:16 -07:00
Ola Hungerford
cae7c76358 Fix formatting 2025-03-16 15:31:49 -07:00
Ola Hungerford
50a65d0c7a Use generateDefaultValue for object and array defaults 2025-03-16 15:29:59 -07:00
Ola Hungerford
e1b015e40d Add comments explaining extra parsing logic 2025-03-16 15:28:07 -07:00
Ola Hungerford
7c4ed6abca Use working-directory instead of cd to client 2025-03-16 15:24:48 -07:00
Ola Hungerford
a3740c4798 Remove unneeded DynamicJsonForm.tsx 2025-03-16 15:24:34 -07:00
cliffhall
d8b5bdb613 Run prettier 2025-03-15 17:03:22 -04:00
cliffhall
5104952239 Add log level setting in UI
* This fixes #188
* In App.tsx
  - import LoggingLevel from sdk
  - add [logLevel, setLogLevel] useState with value of type LoggingLevel initialized to "debug"
  - add useEffect that stores the new logLevel in localStorage as "logLevel"
  - added sendLogLevelRequest function that takes a level argument of type LoggingLevel and sends the appropriate request. It calls setLogLevel when done, to update the local UI
  - pass logLevel and sendLogLevelRequest to Sidebar component as props
* In Sidebar.tsx
  - Import LoggingLevel and LoggingLevelSchema from sdk
  - add props and prop types for  logLevel and sendLogLevelRequest and loggingSupported
  - add Select component populated with the enum values of LoggingLevelSchema, shown only if loggingSupported is true and connectionStatus is "connected"
*
2025-03-15 16:37:10 -04:00
Ryan Rozich
67722aea71 Merge branch 'main' into fix-env-var-parsing 2025-03-15 14:27:46 -05:00
Ola Hungerford
cedf02d152 Merge pull request #185 from cliffhall/add-logging-message-handler
Add support for server logging messages
2025-03-15 09:44:22 -07:00
Cliff Hall
f01f02d5be Merge branch 'modelcontextprotocol:main' into add-logging-message-handler 2025-03-15 12:38:27 -04:00
Ola Hungerford
56932e8a93 Merge pull request #178 from lloydzhou/main
Add SSE 'Accept' header
2025-03-15 06:29:18 -07:00
lloydzhou
aeaf32fa45 fix 2025-03-15 12:58:11 +08:00
lloydzhou
090b7efdea add sse accept header 2025-03-15 12:58:11 +08: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
cliffhall
d70e6dc0e8 In useConnection.ts,
- import LoggingMessageNotificationSchema
- set onNotification as notification handler for LoggingMessageNotificationSchema
2025-03-13 15:17:17 -04:00
Ola Hungerford
1f214deeab Merge branch 'main' into handle-empty-json-fields 2025-03-13 06:17:18 -07:00
Ola Hungerford
c77252900a Merge pull request #180 from seuros/version
feat: Fetch version from package.json in useConnection hook
2025-03-12 13:48:11 -07:00
Ola Hungerford
498c02b0f1 Merge pull request #182 from leoshimo/leoshimo/181-sampling-dark-mode
fix: add dark mode support to SamplingTab JSON display (#181)
2025-03-12 13:06:24 -07:00
Ola Hungerford
60c4645eaf Fix formatting 2025-03-12 08:20:11 -07:00
Ryan Rozich
fe8b1ee88b remove comments 2025-03-11 22:46:38 -05:00
Ryan Rozich
04a90e8d89 Fix environment variable parsing to handle values with equals signs 2025-03-11 22:46:38 -05:00
leoshimo
e5ee00bf89 fix: add dark mode support to SamplingTab JSON display (#181) 2025-03-11 12:59:28 -07:00
Abdelkader Boudih
397a0f651f feat: Fetch version from package.json in useConnection hook 2025-03-11 13:33:45 +00:00
Justin Spahr-Summers
0281e5f821 Fix formatting 2025-03-11 10:56:53 +00:00
Justin Spahr-Summers
f56961ac62 Bump version 2025-03-11 10:55:14 +00:00
Justin Spahr-Summers
15bbb7502b Merge pull request #175 from avi1mizrahi/main
Add Bearer Token Support
2025-03-11 10:50:51 +00:00
Ola Hungerford
7caf6f8ba8 Merge pull request #170 from cliffhall/add-subscribe-to-resource
Add subscribe to resource functionality
2025-03-10 21:08:46 -07:00
Avi Mizrahi
dbd616905c Support bearer token 2025-03-10 17:50:12 +02:00
Ola Hungerford
1ff410ca3d Merge branch 'main' into handle-empty-json-fields 2025-03-10 05:46:57 -07:00
Cliff Hall
35a0f4611a Merge branch 'main' into add-subscribe-to-resource 2025-03-08 15:35:54 -05:00
cliffhall
952bee2605 Fix prettier complaints 2025-03-08 15:08:12 -05:00
cliffhall
a669272fda Track subscribed resources and show the appropriate subscribe or unsubscribe button on selected resource panel.
If the server does not support resource subscriptions, do not show any subscription buttons.

* In App.tsx
  - useState for resourceSubscriptions, setResourceSubscriptions a Set of type string.
  - in subscribeToResource()
    - only make the request to subscribe if the uri is not in the resourceSubscriptions set
  - in unsubscribeFromResource()
    - only make the request to unsubscribe if the uri is in the resourceSubscriptions set
  - in ResourceTab element,
    - pass a boolean resourceSubscriptionsSupported as serverCapabilities.resources.subscribe
    - pass resourceSubscriptions as a prop
* In ResourcesTab.tsx
  - deconstruct resourceSubscriptions and resourceSubscriptionsSupported from props and add prop type
  - in selected resource panel
    - don't show subscribe or unsubscribe buttons unless resourceSubscriptionsSupported is true
    - only show subscribe button if selected resource uri is not in resourceSubscriptions set
    - only show unsubscribe button if selected resource uri is in resourceSubscriptions set
    - wrap buttons in a flex div that is
      - justified right
      - has a minimal gap between
      - 2/5 wide (just big enough to contain two buttons and leave the h3 text 3/5 of the row to render and not overflow.
2025-03-08 13:40:37 -05:00
cliffhall
747c0154c5 WIP: Subscribe to resources
* In App.tsx
  - added subscribeToResource()
    - takes a uri
    - sends a `resources/subscribe` message with the uri
  - added unsubscribeFromResource()
    - takes a uri
    - sends a `resources/unsubscribe` message with the uri
  - in ResourcesTab element,
    - pass subscribeToResource and subscribeToResource invokers to component
* In notificationTypes.ts
  - add ServerNotificationSchema to NotificationSchema to permit server update messages.

* In ResourcesTab.tsx
  - deconstruct subscribeToResource and unsubscribeFromResource and add prop types
  - Add Subscribe and Unsubscribe buttons to selected resource panel, left of the refresh button. They call the sub and unsub functions that came in on props, passing the selected resource URI.
  - [WIP]: Will show the appropriate button in a follow up commit.
* In useConnection.ts
  - import ResourceUpdatedNotificationSchema
  - in the connect function,
    - set onNotification as the handler for ResourceUpdatedNotificationSchema
2025-03-08 11:05:13 -05:00
Ola Hungerford
0870a81990 Merge pull request #169 from cliffhall/dial-back-ping-button-energy
Removing the all the hype from the ping button.
2025-03-07 19:02:37 -07:00
cliffhall
ca18faa7c3 Removing the all the hype from the ping button.
Discussion at:
https://github.com/orgs/modelcontextprotocol/discussions/186
2025-03-07 13:05:45 -05:00
Ola Hungerford
014730fb2f Merge pull request #164 from TornjV/patch-1
Restore timeout search param
2025-03-06 10:22:33 -07:00
Veljko Tornjanski
9c690e004b Update useConnection.ts 2025-03-05 18:17:39 +01:00
Ola Hungerford
b9b116a5f2 Remove duplicate react-dialog from merge 2025-03-05 07:55:15 -07:00
Ola Hungerford
4efe7d7899 Merge branch 'main' into handle-empty-json-fields 2025-03-05 07:52:40 -07:00
Jerome
027eb02422 Merge pull request #162 from modelcontextprotocol/jerome/fix/versioning
Bumped versions of sub packages
2025-03-05 13:02:58 +00:00
Jerome
b116264f90 Bumped versions of sub packages 2025-03-05 12:55:55 +00:00
Jerome
290d5ab49e Bumping version for 0.5.1 release 2025-03-05 11:59:19 +00:00
Jerome
826ce37d2c Merge pull request #143 from modelcontextprotocol/justin/sdk-auth
Refactor to use auth from SDK
2025-03-05 11:58:03 +00:00
Jerome
7a56a7200c Updated mcp sdk to 1.6.1 2025-03-05 11:52:16 +00:00
Jerome
1eba99c531 Merge branch 'main' into justin/sdk-auth 2025-03-05 11:43:20 +00:00
Ola Hungerford
00836dbf9e Fix formatting 2025-03-04 20:55:57 -07:00
Ola Hungerford
dd02b69036 Merge branch 'handle-empty-json-fields' of https://github.com/olaservo/inspector into handle-empty-json-fields 2025-03-04 20:54:16 -07:00
Ola Hungerford
f9b105c0ef Use debounce instead 2025-03-04 20:54:13 -07:00
Ola Hungerford
1ae77e9ef8 Merge branch 'main' into handle-empty-json-fields 2025-02-28 17:25:38 -07:00
Ashwin Bhat
13ae2b5659 Merge pull request #158 from modelcontextprotocol/ashwin/claudemd
docs: add CLAUDE.md for development guidance
2025-02-28 16:24:19 -08:00
Ola Hungerford
06773bb6dd Fix formatting 2025-02-28 09:13:33 -07:00
Ola Hungerford
b01e386659 Always use JSON mode if the schema type is object and has no properties 2025-02-28 09:06:51 -07:00
Ola Hungerford
e7f55f083f Fix formatting 2025-02-28 07:34:01 -07:00
Ola Hungerford
36aa7316ea Fix issue where array type defaults to object 2025-02-28 07:31:26 -07:00
Ola Hungerford
0e50b68f96 Fix formatting 2025-02-28 06:30:23 -07:00
Ola Hungerford
a1eb343b79 Remove unused function plus tests 2025-02-28 06:26:04 -07:00
Ola Hungerford
82bbe58a46 Fix formatting 2025-02-27 22:08:04 -07:00
Ola Hungerford
44982e6c97 Default to nulls and update tests 2025-02-27 21:33:37 -07:00
Ola Hungerford
6ec82e21b1 Remove some fluff 2025-02-27 07:48:19 -07:00
Ola Hungerford
abd4877dae Revert to only run on main 2025-02-27 07:37:47 -07:00
Ola Hungerford
d1f5b3b933 Fix formatting 2025-02-27 07:30:38 -07:00
Ola Hungerford
720480cbbb Remove console.warn and extra comments to reduce code noise 2025-02-27 07:28:13 -07:00
Ola Hungerford
8ac7ef0985 Fix path to client 2025-02-27 07:23:40 -07:00
Ola Hungerford
238c22830b Fix formatting 2025-02-27 07:21:04 -07:00
Ola Hungerford
426fb87640 Remove comment ans trigger workflow run 2025-02-27 07:15:31 -07:00
Ola Hungerford
90ce628040 Test workflow in my branch 2025-02-27 06:54:36 -07:00
Ola Hungerford
d4a64fb5d8 Add client tests to workflow 2025-02-27 06:52:19 -07:00
Ola Hungerford
ede1ea0faa Merge branch 'main' into handle-empty-json-fields 2025-02-27 06:42:17 -07:00
Ola Hungerford
0747479694 Handle edge case and add tests for functions 2025-02-27 06:40:01 -07:00
Justin Spahr-Summers
db1b5cbc45 Merge pull request #113 from gavinaboulhosn/feature/completions
Implement MCP Completion Support in Prompts and Resources Tabs
2025-02-27 11:51:47 +00:00
Ola Hungerford
0b105b29c1 Extract functions 2025-02-26 19:55:01 -07:00
Ola Hungerford
0e29e2c1cf Resolve issues where JSON fields are not being rendered in form mode 2025-02-26 19:34:33 -07:00
Ashwin Bhat
989efb2204 docs: add CLAUDE.md for development guidance
Created a CLAUDE.md file containing build commands, code style guidelines, and project organization information for future development work.

🤖 Generated with [Claude Code](https://docs.anthropic.com/s/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-02-26 11:17:54 -08:00
Ola Hungerford
592dacad39 Start adding changes to address json fields 2025-02-26 09:50:47 -07:00
Ola Hungerford
717c394d3b Merge pull request #157 from kshern/feature/add-array-proptype
Add an array input field to the ToolsTab using DynamicJsonForm.
2025-02-24 09:28:36 -07:00
kshern
8267e514ce fix: prettier write 2025-02-25 00:09:04 +08:00
kshern
18438dbdd0 feat: add error handling 2025-02-24 02:52:17 +08:00
kshern
07577fc94b feat:toolsbar add array support 2025-02-24 02:36:52 +08:00
Jerome
88984c7bc7 Linting 2025-02-23 15:15:28 +00:00
Ola Hungerford
b4870b3da3 Merge branch 'main' into feature/completions 2025-02-21 08:43:32 -07:00
Jerome
19ee9fa86a Merge remote-tracking branch 'origin/main' into justin/sdk-auth 2025-02-21 15:31:09 +00:00
Jerome
e28a64c932 Merge pull request #155 from modelcontextprotocol/jerome/fix/oauth-routing-in-prod-builds
Fix OAuth callback route in production builds
2025-02-21 15:08:25 +00:00
Jerome
02479d3ea9 Updating imports 2025-02-21 15:03:14 +00:00
Jerome
c3ece186a4 Using urlencoded params for refresh/auth tokens 2025-02-21 14:51:47 +00:00
Jerome
4201b31a24 Fix OAuth callback route in production builds
Add SPA routing configuration to serve-handler to ensure /oauth/callback
routes are correctly handled in production builds.
2025-02-21 14:47:31 +00:00
Ola Hungerford
50638806cb Merge branch 'main' into feature/completions 2025-02-21 06:19:41 -07:00
Justin Spahr-Summers
27880974a2 Merge pull request #148 from olaservo/add-note-for-dev-mode-on-windows
Add note for dev mode on windows
2025-02-19 15:31:58 +00:00
Ola Hungerford
266e8bec98 Fix formatting 2025-02-19 08:27:51 -07:00
Ola Hungerford
ed59974d65 Add dev:windows to server instead and reference from root package.json 2025-02-19 08:26:14 -07:00
Ola Hungerford
8e06165d73 Revert unintentional package-lock change 2025-02-19 08:23:01 -07:00
Ola Hungerford
e22d3c76bf Revert unintentional package-lock change 2025-02-19 08:22:02 -07:00
Ola Hungerford
02f53005db Merge branch 'main' into add-note-for-dev-mode-on-windows 2025-02-19 08:17:15 -07:00
Justin Spahr-Summers
3893807841 Merge pull request #153 from funwarioisii/fix-tool-call-textarea
Fix ToolsTab Textarea value for clearing when switch tool
2025-02-19 15:08:42 +00:00
Ola Hungerford
7b40aed43b Add dev:windows command 2025-02-19 08:08:28 -07:00
Justin Spahr-Summers
0f304a37ae Merge pull request #150 from olaservo/handle-boolean
Add checkbox for handling boolean input params
2025-02-19 15:06:56 +00:00
Ola Hungerford
39970db604 Merge branch 'main' into add-note-for-dev-mode-on-windows 2025-02-19 07:58:36 -07:00
funwarioisii
f505ae3d5a Fix ToolsTab Textarea value for clearing when switch tool 2025-02-19 23:55:24 +09:00
Ola Hungerford
89ee2b1b93 Merge branch 'main' into handle-boolean 2025-02-19 07:48:27 -07:00
Ola Hungerford
450405733a Merge: Integrate boolean checkbox support with SchemaProperty type system 2025-02-19 07:38:23 -07:00
Justin Spahr-Summers
d4df126112 Merge pull request #115 from jacksteamdev/bugfix/issue-114
fix(server) Avoid modifying path resolution for executables on MacOS
2025-02-19 14:16:51 +00:00
Justin Spahr-Summers
7065d70e34 Merge pull request #136 from olaservo/tool-input-improvements
Add enhanced object input editing
2025-02-19 14:08:24 +00:00
Jack Steam
ad004bc2f7 Merge branch 'main' into bugfix/issue-114 2025-02-18 11:54:09 -06:00
Jack Steam
db6494353c chore: update spawn-rx to v5.1.2 2025-02-18 11:53:56 -06:00
Jack Steam
3408be3e55 Merge branch 'bugfix/issue-114' of https://github.com/jacksteamdev/inspector into bugfix/issue-114 2025-02-18 11:53:04 -06:00
Jack Steam
406828ade2 Revert "fix(server) Differentiate command resolution by platform"
This reverts commit 0fa56e14d9.
2025-02-18 11:50:53 -06:00
Ola Hungerford
44d07b964c Fix styling 2025-02-15 05:05:29 -07:00
Ola Hungerford
5b2d54ae3b Revert package and check in package-lock 2025-02-15 04:47:20 -07:00
Ola Hungerford
f7312ab331 Add checkbox for Boolean input params 2025-02-15 04:33:08 -07:00
Ola Hungerford
59e7639d39 Revert package change 2025-02-14 07:44:50 -07:00
Ola Hungerford
133b785f79 Fix formatting 2025-02-14 07:33:37 -07:00
Ola Hungerford
f6860a88f9 Update package lock 2025-02-14 07:23:19 -07:00
Ola Hungerford
b3194ac56e Merge branch 'main' into tool-input-improvements 2025-02-14 07:20:45 -07:00
Ola Hungerford
7c57e823bd Fix style issues 2025-02-14 06:25:35 -07:00
Ola Hungerford
b8e73886dd Revert other changes 2025-02-14 06:18:44 -07:00
Ola Hungerford
2a1536d2ab Revert other changes 2025-02-14 06:16:02 -07:00
Ola Hungerford
348cff9872 Add note on dev mode for Windows users 2025-02-14 06:09:40 -07:00
Ola Hungerford
beee38387c Merge branch 'main' of https://github.com/olaservo/inspector 2025-02-14 06:07:46 -07:00
Justin Spahr-Summers
7b3dff68c0 Merge pull request #145 from matv-stripe/patch-1
Update README to include node before the command
2025-02-13 23:03:28 +00:00
Gavin Aboulhosn
d9df5ff860 refactor(completions): restore debouncing and improve MCP error handling 2025-02-12 20:19:36 -05:00
Gavin Aboulhosn
5b451a7cfe Merge branch 'modelcontextprotocol:main' into feature/completions 2025-02-12 19:10:06 -05:00
Gavin Aboulhosn
7f713fe40e refactor(completions): improve completion handling and error states
- Move completion logic from App.tsx to useConnection hook
- Replace useCompletion with simpler useCompletionState hook
- Add graceful fallback for servers without completion support
- Improve error handling and state management
- Update PromptsTab and ResourcesTab to use new completion API
- Add type safety improvements across completion interfaces
2025-02-12 19:05:51 -05:00
Justin Spahr-Summers
fa723abbe0 Merge pull request #146 from modelcontextprotocol/justin/fix-versions
Bump all versions to 0.4.1
2025-02-12 18:11:54 +00:00
Justin Spahr-Summers
410a6f33dc Format fixes 2025-02-12 18:10:25 +00:00
Justin Spahr-Summers
b324378b2c Bump all versions to 0.4.1 2025-02-12 18:08:31 +00:00
matv-stripe
e427f7bca5 Update README.md 2025-02-12 12:26:41 -05:00
Gavin Aboulhosn
c66feff37d update completions branch 2025-02-12 11:41:46 -05:00
Gavin Aboulhosn
9b624e8c87 feat(completions): integrate MCP Completion support into Prompts and Resources tabs
- create useCompletion hook to fetch completions with debouncing and abort control
- Updated `PromptsTab.tsx` and `ResourcesTab.tsx` to utilize the `Combobox` component and `useCompletions` hook, enabling argument autocompletion for prompts and resource URIs as per the MCP specification.
- Added a combobox to show completions
2025-02-12 11:21:27 -05:00
Justin Spahr-Summers
ba99638f48 Comment about SSEClientTransport auth API 2025-02-11 20:43:01 +00:00
Justin Spahr-Summers
f4aefa2706 npm audit fix 2025-02-11 20:36:35 +00:00
Justin Spahr-Summers
e9a50adde7 Update OAuth callback code 2025-02-11 17:42:49 +00:00
Justin Spahr-Summers
eb6af47b21 Refactor to use auth from SDK 2025-02-11 16:39:07 +00:00
Jerome
6d930ecae7 Merge pull request #135 from olaservo/add-server-startup-logging
Add server startup logging
2025-02-11 11:00:45 +13:00
Ola Hungerford
9c3fee1442 Merge branch 'main' into add-server-startup-logging 2025-02-08 13:13:14 -07:00
Justin Spahr-Summers
688752ea77 Merge pull request #139 from allenzhou101/oauth-refresh
Add Refresh Token Support for OAuth
2025-02-06 15:19:21 +00:00
Allen Zhou
1b13b574f8 Update auth.ts 2025-02-05 12:45:11 -08:00
Allen Zhou
95bbd60a38 Add zod parsing for OAuthMetadataSchema and OAuthTokensSchema 2025-02-05 12:42:09 -08:00
Allen Zhou
96ba6fd531 Convert OAuthMetadata and OAuthTokens to zod 2025-02-05 12:38:26 -08:00
Allen Zhou
8592cf2d07 Run prettier-fix 2025-02-05 11:22:11 -08:00
Allen Zhou
dd47b574b3 Update useConnection.ts 2025-02-04 15:02:12 -08:00
Allen Zhou
b4ae1327b5 Update useConnection.ts 2025-02-04 15:00:14 -08:00
Allen Zhou
b5762d53fd Handle infinite loop if server keeps returning 401 2025-02-04 14:53:41 -08:00
Allen Zhou
7957d9f577 Make OAuth start call modular 2025-02-03 20:06:21 -08:00
Allen Zhou
4c89aed4d9 Add check for expired refresh or session token that exists 2025-02-03 20:04:17 -08:00
Allen Zhou
79547143a8 Add refresh token handling if returned from server 2025-02-03 19:53:53 -08:00
Jerome
d438760e36 Merge pull request #99 from evalstate/feature/audio-rendering
Render Audio Player if Tool Result resource mime type is audio.
2025-02-03 12:28:41 +13:00
Jerome
d0ad677784 Update ToolsTab.tsx
Linting on originally approved commit
2025-02-03 12:24:52 +13:00
Ola Hungerford
98b26e9d06 Merge branch 'main' of https://github.com/olaservo/inspector 2025-01-31 05:54:12 -07:00
Ola Hungerford
d007f92302 Update deps 2025-01-29 20:21:26 -07:00
Ola Hungerford
6a3d901a72 Update dev command 2025-01-29 20:16:51 -07:00
Ola Hungerford
58ad8103f7 Remove vercel 2025-01-29 17:07:55 -07:00
Ola Hungerford
ee2c67e1af Test vercel config 2025-01-29 16:59:00 -07:00
Ola Hungerford
7c89a01c99 Test vercel config 2025-01-29 16:56:41 -07:00
Ola Hungerford
fb3d89c6e3 Test vercel config 2025-01-29 16:54:00 -07:00
Ola Hungerford
4b3bb5f34e Add description to array input fields 2025-01-29 11:36:10 -07:00
Justin Spahr-Summers
1d4e8885db Merge pull request #131 from modelcontextprotocol/justin/sse-auth
OAuth support for SSE
2025-01-29 11:20:48 +00:00
=
a87bd17f51 ❯ npx prettier --check .
Checking formatting...
[warn] client/src/components/ToolsTab.tsx
[warn] Code style issues found in the above file. Run Prettier with --write to fix.

inspector on  feature/audio-rendering [$⇡] is 📦 v0.3.0 via  v22.11.0
❯ npx prettier --write client/src/components/ToolsTab.tsx
client/src/components/ToolsTab.tsx 109ms
2025-01-28 08:28:25 +00:00
=
afe14bc883 Merge branch 'feature/audio-rendering' of https://github.com/evalstate/inspector into feature/audio-rendering 2025-01-28 08:18:24 +00:00
Jerome
04faff4757 Merge branch 'main' into feature/audio-rendering 2025-01-28 02:15:55 -05:00
Ola Hungerford
a4469f7895 Add draft version of enhanced object input editing 2025-01-27 21:05:11 -07:00
Ola Hungerford
f980763381 Specify proxy server 2025-01-26 20:46:08 -07:00
Ola Hungerford
d754395a9a Revert tsx watch change 2025-01-26 20:36:31 -07:00
Ola Hungerford
df955cfdb5 Remove other logging and just keep listening and try catch 2025-01-26 20:24:59 -07:00
Ola Hungerford
5b884b55b5 Add server startup logging 2025-01-26 20:13:11 -07:00
Justin Spahr-Summers
0882a3e0e5 Formatting 2025-01-24 15:23:24 +00:00
Justin Spahr-Summers
fce6644e30 Fix double fetching 2025-01-24 15:22:40 +00:00
Justin Spahr-Summers
51ea4bc6ac Add toast when OAuth succeeds 2025-01-24 15:19:41 +00:00
Justin Spahr-Summers
0648ba44e3 Auto-reconnect after OAuth 2025-01-24 15:17:03 +00:00
Justin Spahr-Summers
c22f91858c Remember last selected transport and SSE URL 2025-01-24 15:04:22 +00:00
Justin Spahr-Summers
99d7592ac9 Fix error state being briefly shown before OAuth 2025-01-24 15:02:34 +00:00
Justin Spahr-Summers
3bc776f7cd Fix Vite config 2025-01-24 14:55:10 +00:00
Justin Spahr-Summers
a6d22cf1e4 Bump SDK version 2025-01-24 14:54:46 +00:00
Justin Spahr-Summers
731ee588c2 Fix Authorization header passthrough
Node.js headers are lowercase
2025-01-24 13:55:43 +00:00
Justin Spahr-Summers
af8877064e Set Authorization header from client 2025-01-24 13:55:32 +00:00
Justin Spahr-Summers
874320ebe6 Token exchange body needs to be JSON 2025-01-24 13:44:26 +00:00
Justin Spahr-Summers
e470eb5c51 Fix React import 2025-01-24 13:27:20 +00:00
Justin Spahr-Summers
02cfb47c83 Extract session storage keys into constants 2025-01-24 13:09:58 +00:00
Justin Spahr-Summers
23f89e49b8 Implement OAuth callback 2025-01-24 13:08:39 +00:00
Justin Spahr-Summers
16cb59670c OAuth callback handler (not yet attached) 2025-01-24 11:37:35 +00:00
Justin Spahr-Summers
1c4ad60354 Redirect into OAuth flow upon receiving 401 2025-01-24 11:34:07 +00:00
Justin Spahr-Summers
8a20f7711a Use new SseError class from SDK 2025-01-24 11:27:40 +00:00
Justin Spahr-Summers
8bb5308797 Report SSE 401 errors to the client 2025-01-24 11:04:44 +00:00
Justin Spahr-Summers
14db05c2a2 Clarify inspector-server error logging 2025-01-23 17:19:39 +00:00
Justin Spahr-Summers
e7697eb5cd Pass through Authorization headers sent to inspector server 2025-01-23 16:45:37 +00:00
Justin Spahr-Summers
c1e06c4af0 Server doesn't need to inject eventsource anymore 2025-01-23 16:45:12 +00:00
Justin Spahr-Summers
60b8892dd3 Pre-emptively bump npm package versions
Before I forget!
2025-01-23 16:30:19 +00:00
Justin Spahr-Summers
2b53a8399c Bump SDK 2025-01-23 16:29:43 +00:00
Justin Spahr-Summers
361f9d109b Merge pull request #128 from modelcontextprotocol/dependabot/npm_and_yarn/npm_and_yarn-f25c717a0f
Bump vite from 5.4.11 to 5.4.12 in the npm_and_yarn group across 1 directory
2025-01-22 12:02:57 +00:00
dependabot[bot]
7ec661e8bd 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.11 to 5.4.12
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/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-01-22 04:50:31 +00:00
Jack Steam
f8052dfcda Merge branch 'main' into bugfix/issue-114 2025-01-14 11:44:09 -07:00
Ashwin Bhat
98e6f0e5ec Merge pull request #124 from modelcontextprotocol/ashwin/envvar
allow passing env vars to server from command line
2025-01-13 12:11:25 -08:00
Ashwin Bhat
ec150eb8b4 prettier 2025-01-10 07:53:55 -08:00
Ashwin Bhat
052de8690d respond to PR feedback 2025-01-10 07:51:55 -08:00
Ashwin Bhat
a976aefb39 allow passing env vars to server from command line 2025-01-10 07:51:54 -08:00
Ashwin Bhat
5a5873277c Merge pull request #123 from modelcontextprotocol/ashwin/prettier
enforce prettier formatting
2025-01-10 07:28:29 -08:00
Ashwin Bhat
715936d747 run prettier 2025-01-09 11:01:35 -08:00
Ashwin Bhat
d973f58bef run prettier check in CI 2025-01-09 11:01:28 -08:00
Jack Steam
88ed9c088d Merge branch 'main' into bugfix/issue-114 2024-12-23 14:37:54 -07:00
David Soria Parra
1797fbfba8 Merge pull request #118 from modelcontextprotocol/ashwin/githublink
feat: add GitHub link to sidebar for bug reports and contributions
2024-12-20 10:57:01 +00:00
David Soria Parra
8f4013c42c Merge pull request #117 from modelcontextprotocol/ashwin/state
refactor: extract draggable pane and connection logic into hooks
2024-12-20 10:56:33 +00:00
David Soria Parra
1abb7ca59c Merge pull request #119 from modelcontextprotocol/ashwin/font
feat: use monospace font for all input fields in sidebar
2024-12-20 10:56:05 +00:00
Ashwin Bhat
dfb36e1792 feat: use monospace font for all input fields in sidebar
Makes command, arguments, URL and environment variables easier to read and edit.
2024-12-19 13:18:52 -08:00
Ashwin Bhat
ffc29663c8 fix: reduce theme selector width to 100px to prevent crowding with icon buttons 2024-12-19 13:17:16 -08:00
Ashwin Bhat
53226dd391 feat: add GitHub link to sidebar for bug reports and contributions 2024-12-19 13:06:00 -08:00
Jack Steam
243ee1a6b5 Merge branch 'main' into bugfix/issue-114 2024-12-19 08:24:44 -07:00
Ashwin Bhat
dc49d46baa refactor: extract draggable pane and connection logic into hooks
- Create useDraggablePane hook for history pane drag behavior
- Create useConnection hook for MCP client connection and requests
- Update App.tsx to use both hooks
2024-12-18 12:54:24 -08:00
Ashwin Bhat
ef32a8f289 Merge pull request #116 from modelcontextprotocol/claude/update-readme
feat: add help and debug links to sidebar
2024-12-18 09:14:31 -08:00
David Soria Parra
54e9957ec5 feat: add help and debug links to sidebar 2024-12-18 11:39:24 +00:00
Jack Steam
c78b0fbed6 Merge branch 'main' into bugfix/issue-114 2024-12-17 16:14:59 -07:00
Ashwin Bhat
7edde5001b Merge pull request #112 from modelcontextprotocol/devin/1734088716-fix-object-params
fix: properly handle object type parameters in tools
2024-12-17 14:50:23 -08:00
Jack Steam
0fa56e14d9 fix(server) Differentiate command resolution by platform 2024-12-17 16:49:55 -06:00
Devin AI
14bda1f030 chore: revert unrelated changes to TypeScript comments and formatting
- Reverted @ts-expect-error messages back to original text
- Removed unnecessary line breaks in placeholder and type properties
- Kept object parameter handling functionality intact

Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2024-12-13 12:12:33 +00:00
Devin AI
1f4d35f8a3 fix: properly handle object type parameters in tools
- Add special handling for object type parameters
- Parse JSON input for object parameters
- Maintain raw input if JSON parsing fails
- Fixes #110

Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2024-12-13 11:21:35 +00:00
Ashwin Bhat
eb70539958 Merge pull request #105 from devin-open-source/devin/1733551277-capability-negotiation
feat: implement capability negotiation for UI tabs
2024-12-09 03:56:58 -08:00
Jeffrey Ling
7878e1764a address comment 2024-12-09 04:52:15 -07:00
Jeffrey Ling
26f0cb3c8b merge conflict 2024-12-09 04:43:48 -07:00
Jeffrey Ling
8f40e052c1 Merge remote-tracking branch 'origin/main' into devin/1733551277-capability-negotiation 2024-12-09 04:31:45 -07:00
Jeffrey Ling
024f06c1b7 cleanup diffs 2024-12-09 04:29:30 -07:00
Devin AI
1ddc63b330 refactor: remove disabled state from Sampling and Roots tabs 2024-12-09 11:07:42 +00:00
Devin AI
27bd503240 fix: remove duplicate ServerCapabilities type declarations 2024-12-09 10:37:56 +00:00
Devin AI
b39c96de7c refactor: revert tab files to main and restore tab disabling 2024-12-09 10:36:56 +00:00
Devin AI
d857e1462b refactor: simplify capability handling and remove context provider
- Remove redundant useEffect for capability checking
- Remove CapabilityContext provider pattern
- Set default tab to first supported capability
- Add fallback UI for unsupported capabilities
- Delete unused contexts.ts file
2024-12-09 10:11:03 +00:00
Ashwin Bhat
2eae823d65 Merge pull request #107 from JensWallgren/tool-result-legacy-dark-fix
Add dark mode support for the legacy Tool Result display
2024-12-09 02:03:34 -08:00
David Soria Parra
79ba164fda Merge pull request #108 from 8enmann/ben/persist
feat: improve request history and tab persistence
2024-12-09 09:27:19 +00:00
Ben Mann
2f513df6c1 feat: improve request history and tab persistence
- Add failed requests to history with error messages for better debugging
- Persist selected tab in URL hash and restore on page load
- Fix formatting of timeout parameter parsing

🤖 Generated with Claude CLI.

Co-Authored-By: Claude <noreply@anthropic.com>
2024-12-08 21:15:34 +02:00
Jens Wallgren
ce68085e77 Add dark mode support for the legacy Tool Result display 2024-12-08 14:14:40 +01:00
devin-ai-integration[bot]
e96b3be159 feat: implement capability negotiation for UI tabs
- Add CapabilityContext to manage server capabilities
- Disable tabs when server doesn't support feature
- Show error message in tab content when capability missing
- Implements #85
2024-12-07 06:15:21 +00:00
Ashwin Bhat
034699524a Merge pull request #104 from devin-open-source/fix-shell-quote
Fix shell argument parsing issue: #96
2024-12-06 17:07:50 -08:00
Jeffrey Ling
fdc521646f no need to prettier format everything right now 2024-12-06 12:48:48 -07:00
devin-ai-integration[bot]
bd6586bbad style: apply prettier formatting 2024-12-06 19:43:48 +00:00
devin-ai-integration[bot]
c340e5f1ed chore: move shell-quote to main dependencies 2024-12-06 19:43:11 +00:00
devin-ai-integration[bot]
cc1ae05f9d fix: use shell-quote for proper argument parsing 2024-12-06 19:34:03 +00:00
devin-ai-integration[bot]
9ea77a729c chore: add shell-quote package and types 2024-12-06 19:33:08 +00:00
Ashwin Bhat
8c7b0c360e Merge pull request #103 from evalstate/fix/tool-tab-parameters
Tool Tab - Parameter Handling Fixes.
2024-12-05 17:51:05 -08:00
evalstate
35effc4d16 Merge branch 'main' into feature/audio-rendering 2024-12-05 19:50:21 +00:00
evalstate
ed5017d73e Two fixes to the Tools Tab:
1) Tool Parameters were stale when switching between Tools causing incorrect messages to be sent.
2) Tool List is emptied when "Clear" is selected, so invalid messages can't be sent.
2024-12-05 15:32:55 +00:00
evalstate
14802b8043 Merge branch 'main' into feature/audio-rendering 2024-12-03 17:11:47 +00:00
=
068d21387a extended type for "audio" update to spec 2024-12-01 12:47:50 +00:00
=
66b1b73448 Render HTML5 Audio Player if Tool Result resource mimetype is audio. 2024-12-01 10:24:12 +00:00
56 changed files with 9050 additions and 699 deletions

View File

@@ -14,6 +14,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: npx prettier --check .
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -22,6 +25,11 @@ jobs:
# Working around https://github.com/npm/cli/issues/4828
# - run: npm ci
- run: npm install --no-package-lock
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build
publish:

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ server/build
client/dist
client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo
.vscode

View File

@@ -1,2 +1,4 @@
packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md

33
CLAUDE.md Normal file
View File

@@ -0,0 +1,33 @@
# MCP Inspector Development Guide
## Build Commands
- Build all: `npm run build`
- Build client: `npm run build-client`
- Build server: `npm run build-server`
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
- Format code: `npm run prettier-fix`
- Client lint: `cd client && npm run lint`
## Code Style Guidelines
- Use TypeScript with proper type annotations
- Follow React functional component patterns with hooks
- Use ES modules (import/export) not CommonJS
- Use Prettier for formatting (auto-formatted on commit)
- Follow existing naming conventions:
- camelCase for variables and functions
- PascalCase for component names and types
- kebab-case for file names
- Use async/await for asynchronous operations
- Implement proper error handling with try/catch blocks
- Use Tailwind CSS for styling in the client
- Keep components small and focused on a single responsibility
## Project Organization
The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript
- `bin/`: CLI scripts

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
2. Install dependencies with `npm install`
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
1. Create a new branch for your changes
2. Make your changes following existing code style and conventions
3. Test changes locally
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 by running `npm test`
4. Update documentation as needed
5. Use clear commit messages explaining your changes
6. Verify all changes work as expected

View File

@@ -11,22 +11,48 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
```bash
npx @modelcontextprotocol/inspector build/index.js
npx @modelcontextprotocol/inspector node build/index.js
```
You can also pass arguments along which will get passed as arguments to your MCP server:
```
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
```
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
```bash
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
# Pass arguments only
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
# Pass environment variables only
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
# Pass both environment variables and arguments
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
# Use -- to separate inspector flags from server arguments
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector).
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
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### Authentication
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
### 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 |
### From this repository
@@ -38,6 +64,13 @@ Development mode:
npm run dev
```
> **Note for Windows users:**
> On Windows, use the following command instead:
>
> ```bash
> npm run dev:windows
> ```
Production mode:
```bash

View File

@@ -7,12 +7,42 @@ import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function main() {
// Get command line arguments
const [, , command, ...mcpServerArgs] = process.argv;
// Parse command line arguments
const args = process.argv.slice(2);
const envVars = {};
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (parsingFlags && arg === "--") {
parsingFlags = false;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value;
} else {
envVars[envVar] = "";
}
} else if (!command) {
command = arg;
} else {
mcpServerArgs.push(arg);
}
}
const inspectorServerPath = resolve(
__dirname,
@@ -31,8 +61,8 @@ async function main() {
"cli.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173";
const SERVER_PORT = process.env.SERVER_PORT ?? "3000";
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
@@ -43,8 +73,9 @@ async function main() {
cancelled = true;
abort.abort();
});
const server = spawnPromise(
let server, serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
@@ -52,30 +83,31 @@ async function main() {
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: { ...process.env, PORT: SERVER_PORT },
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
const client = spawnPromise("node", [inspectorClientPath], {
// 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,
});
// Make sure our server/client didn't immediately fail
await Promise.any([server, client, delay(2 * 1000)]);
const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`;
console.log(
`\n🔍 MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} 🚀`,
);
try {
await Promise.any([server, client]);
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
return 0;
}

View File

@@ -9,8 +9,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => {
return handler(request, response, { public: distPath });
return handler(request, response, {
public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }],
});
});
const port = process.env.PORT || 5173;
server.listen(port, () => {});
const port = process.env.PORT || 6274;
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);

33
client/jest.config.cjs Normal file
View File

@@ -0,0 +1,33 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
jsx: "react-jsx",
tsconfig: "tsconfig.jest.json",
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
coveragePathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.3.0",
"version": "0.8.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -18,20 +18,30 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview --port 6274",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"@modelcontextprotocol/sdk": "^1.6.1",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"pkce-challenge": "^4.1.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1",
"react-toastify": "^10.0.6",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
@@ -40,18 +50,25 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"

View File

@@ -1,36 +1,27 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
CompatibilityCallToolResult,
CompatibilityCallToolResultSchema,
CreateMessageRequestSchema,
CreateMessageResult,
EmptyResultSchema,
GetPromptResultSchema,
ListPromptsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
ProgressNotificationSchema,
ReadResourceResultSchema,
Request,
Resource,
ResourceTemplate,
Result,
Root,
ServerNotification,
Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js";
import { useCallback, useEffect, useRef, useState } from "react";
import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import {
Notification,
StdErrNotification,
StdErrNotificationSchema,
} from "./lib/notificationTypes";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -43,7 +34,7 @@ import {
} from "lucide-react";
import { toast } from "react-toastify";
import { ZodType } from "zod";
import { z } from "zod";
import "./App.css";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
@@ -54,18 +45,16 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000";
const REQUEST_TIMEOUT = parseInt(params.get("timeout") ?? "") || DEFAULT_REQUEST_TIMEOUT_MSEC;
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
const PROXY_PORT = params.get("proxyPort") ?? "6277";
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const App = () => {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
// Handle OAuth callback route
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
@@ -88,12 +77,15 @@ const App = () => {
return localStorage.getItem("lastArgs") || "";
});
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
);
});
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
@@ -101,6 +93,14 @@ const App = () => {
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
return savedConfig ? JSON.parse(savedConfig) : DEFAULT_INSPECTOR_CONFIG;
});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@@ -112,25 +112,13 @@ const App = () => {
const nextRequestId = useRef(0);
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>(
null,
);
const [resourceSubscriptions, setResourceSubscriptions] = useState<
Set<string>
>(new Set<string>());
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [nextResourceCursor, setNextResourceCursor] = useState<
@@ -144,49 +132,46 @@ const App = () => {
>();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [historyPaneHeight, setHistoryPaneHeight] = useState<number>(300);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = historyPaneHeight;
document.body.style.userSelect = "none";
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest: makeConnectionRequest,
sendNotification,
handleCompletion,
completionsSupported,
connect: connectMcpServer,
disconnect: disconnectMcpServer,
} = useConnection({
transportType,
command,
args,
sseUrl,
env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL,
requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
[historyPaneHeight],
);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHistoryPaneHeight(newHeight);
onStdErrNotification: (notification) => {
setStdErrNotifications((prev) => [
...prev,
notification as StdErrNotification,
]);
},
[isDragging],
);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
}
}, [isDragging, handleDragMove, handleDragEnd]);
onPendingRequest: (request, resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
]);
},
getRoots: () => rootsRef.current,
});
useEffect(() => {
localStorage.setItem("lastCommand", command);
@@ -196,6 +181,39 @@ const App = () => {
localStorage.setItem("lastArgs", args);
}, [args]);
useEffect(() => {
localStorage.setItem("lastSseUrl", sseUrl);
}, [sseUrl]);
useEffect(() => {
localStorage.setItem("lastTransportType", transportType);
}, [transportType]);
useEffect(() => {
localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
const serverUrl = params.get("serverUrl");
if (serverUrl) {
setSseUrl(serverUrl);
setTransportType("sse");
// Remove serverUrl from URL without reloading the page
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth
toast.success("Successfully authenticated with OAuth");
// Connect to the server
connectMcpServer();
}
}, [connectMcpServer]);
useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`)
.then((response) => response.json())
@@ -217,75 +235,51 @@ const App = () => {
rootsRef.current = roots;
}, [roots]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
useEffect(() => {
if (!window.location.hash) {
window.location.hash = "resources";
}
}, []);
const 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) => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
const makeRequest = async <T extends ZodType<object>>(
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, REQUEST_TIMEOUT);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: abortController.signal,
});
} finally {
clearTimeout(timeoutId);
}
pushHistory(request, response);
const response = await makeConnectionRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e: unknown) {
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey === undefined) {
toast.error(errorString);
} else {
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
pushHistory(notification);
} catch (e: unknown) {
toast.error((e as Error).message ?? String(e));
throw e;
}
};
@@ -332,6 +326,38 @@ const App = () => {
setResourceContent(JSON.stringify(response, null, 2));
};
const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/subscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.add(uri);
setResourceSubscriptions(clone);
}
};
const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/unsubscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.delete(uri);
setResourceSubscriptions(clone);
}
};
const listPrompts = async () => {
const response = await makeRequest(
{
@@ -392,79 +418,28 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
const connectMcpServer = async () => {
try {
const client = new Client<Request, Notification, Result>(
const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest(
{
name: "mcp-inspector",
version: "0.0.1",
},
{
capabilities: {
// Support all client capabilities since we're an inspector tool
sampling: {},
roots: {
listChanged: true,
},
},
method: "logging/setLevel" as const,
params: { level },
},
z.object({}),
);
const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
const clientTransport = new SSEClientTransport(backendUrl);
client.setNotificationHandler(
ProgressNotificationSchema,
(notification) => {
setNotifications((prevNotifications) => [
...prevNotifications,
notification,
]);
},
);
client.setNotificationHandler(
StdErrNotificationSchema,
(notification) => {
setStdErrNotifications((prevErrorNotifications) => [
...prevErrorNotifications,
notification,
]);
},
);
await client.connect(clientTransport);
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise<CreateMessageResult>((resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
]);
});
});
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: rootsRef.current };
});
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
setLogLevel(level);
};
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
return (
<div className="flex h-screen bg-background">
<Sidebar
@@ -479,23 +454,56 @@ const App = () => {
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs defaultValue="resources" className="w-full p-4">
<Tabs
defaultValue={
Object.keys(serverCapabilities ?? {}).includes(
window.location.hash.slice(1),
)
? window.location.hash.slice(1)
: serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping"
}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<TabsList className="mb-4 p-0">
<TabsTrigger value="resources">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger value="prompts">
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger value="tools">
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
@@ -519,6 +527,16 @@ const App = () => {
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
</p>
</div>
) : (
<>
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
@@ -547,6 +565,20 @@ const App = () => {
clearError("resources");
setSelectedResource(resource);
}}
resourceSubscriptionsSupported={
serverCapabilities?.resources?.subscribe || false
}
resourceSubscriptions={resourceSubscriptions}
subscribeToResource={(uri) => {
clearError("resources");
subscribeToResource(uri);
}}
unsubscribeFromResource={(uri) => {
clearError("resources");
unsubscribeFromResource(uri);
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
resourceContent={resourceContent}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
@@ -571,6 +603,8 @@ const App = () => {
clearError("prompts");
setSelectedPrompt(prompt);
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}
@@ -620,6 +654,8 @@ const App = () => {
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
</>
)}
</div>
</Tabs>
) : (

View File

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

View File

@@ -0,0 +1,421 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
interface DynamicJsonFormProps {
schema: JsonSchemaType;
value: JsonValue;
onChange: (value: JsonValue) => void;
maxDepth?: number;
}
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
}
}, 300);
},
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
}
};
const 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 = (
propSchema: JsonSchemaType,
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
) => {
if (
depth >= maxDepth &&
(propSchema.type === "object" || propSchema.type === "array")
) {
// Render as JSON editor when max depth is reached
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
null,
2,
)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
}}
error={jsonError}
/>
);
}
switch (propSchema.type) {
case "string":
return (
<Input
type="text"
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number":
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer":
return (
<Input
type="number"
step="1"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "boolean":
return (
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
/>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">{propSchema.description}</p>
)}
{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}
<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default:
return null;
}
};
const handleFieldChange = (path: string[], fieldValue: JsonValue) => {
if (path.length === 0) {
onChange(fieldValue);
return;
}
try {
const newValue = updateValueAtPath(value, path, fieldValue);
onChange(newValue);
} catch (error) {
console.error("Failed to update form value:", error);
onChange(value);
}
};
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return (
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}>
Format JSON
</Button>
)}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
</div>
{isJsonMode ? (
<JsonEditor
value={rawJsonValue}
onChange={(newValue) => {
// Always update local state
setRawJsonValue(newValue);
// Use the debounced function to attempt parsing and updating parent
debouncedUpdateParent(newValue);
}}
error={jsonError}
/>
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : (
renderFormFields(schema, value)
)}
</div>
);
};
export default DynamicJsonForm;

View File

@@ -1,6 +1,7 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { Copy } from "lucide-react";
import { useState } from "react";
import JsonView from "./JsonView";
const HistoryAndNotifications = ({
requestHistory,
@@ -74,9 +75,9 @@ const HistoryAndNotifications = ({
<Copy size={16} />
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(JSON.parse(request.request), null, 2)}
</pre>
<div className="bg-background p-2 rounded">
<JsonView data={request.request} />
</div>
</div>
{request.response && (
<div className="mt-2">
@@ -91,13 +92,9 @@ const HistoryAndNotifications = ({
<Copy size={16} />
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(
JSON.parse(request.response),
null,
2,
)}
</pre>
<div className="bg-background p-2 rounded">
<JsonView data={request.response} />
</div>
</div>
)}
</>
@@ -146,9 +143,11 @@ const HistoryAndNotifications = ({
<Copy size={16} />
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(notification, null, 2)}
</pre>
<div className="bg-background p-2 rounded">
<JsonView
data={JSON.stringify(notification, null, 2)}
/>
</div>
</div>
)}
</li>

View File

@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
import "prismjs/components/prism-json";
import "prismjs/themes/prism.css";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
error?: string;
}
const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value || "");
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value || "");
}, [value]);
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
onChange(newContent);
};
const displayError = internalError || externalError;
return (
<div className="relative">
<div
className={`border rounded-md ${
displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`}
>
<Editor
value={editorContent}
onValueChange={handleEditorChange}
highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json")
}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
backgroundColor: "transparent",
minHeight: "100px",
}}
className="w-full"
/>
</div>
{displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div>
);
};
export default JsonEditor;

View File

@@ -0,0 +1,228 @@
import { useState, memo } from "react";
import { JsonValue } from "./DynamicJsonForm";
import clsx from "clsx";
interface JsonViewProps {
data: unknown;
name?: string;
initialExpandDepth?: number;
}
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 }: JsonViewProps) => {
const normalizedData =
typeof data === "string"
? tryParseJson(data).success
? tryParseJson(data).data
: data
: data;
return (
<div className="font-mono text-sm transition-all duration-300 ">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
/>
</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

@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
const OAuthCallback = () => {
const hasProcessedRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
// Skip if we've already processed this callback
if (hasProcessedRef.current) {
return;
}
hasProcessedRef.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
if (!code || !serverUrl) {
console.error("Missing code or server URL");
window.location.href = "/";
return;
}
try {
const result = await auth(authProvider, {
serverUrl,
authorizationCode: code,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
} catch (error) {
console.error("OAuth callback error:", error);
window.location.href = "/";
}
};
void handleCallback();
}, []);
return (
<div className="flex items-center justify-center h-screen">
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
</div>
);
};
export default OAuthCallback;

View File

@@ -7,11 +7,9 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-6 px-12 rounded-full shadow-lg transform transition duration-300 hover:scale-110 focus:outline-none focus:ring-4 focus:ring-purple-300 animate-pulse"
className="font-bold py-6 px-12 rounded-full"
>
<span className="text-3xl mr-2">🚀</span>
MEGA PING
<span className="text-3xl ml-2">💥</span>
Ping Server
</Button>
</div>
</TabsContent>

View File

@@ -1,13 +1,19 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js";
import {
ListPromptsResult,
PromptReference,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
export type Prompt = {
name: string;
@@ -26,6 +32,8 @@ const PromptsTab = ({
getPrompt,
selectedPrompt,
setSelectedPrompt,
handleCompletion,
completionsSupported,
promptContent,
nextCursor,
error,
@@ -36,14 +44,37 @@ const PromptsTab = ({
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
promptContent: string;
nextCursor: ListPromptsResult["nextCursor"];
error: string | null;
}) => {
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
const handleInputChange = (argName: string, value: string) => {
useEffect(() => {
clearCompletions();
}, [clearCompletions, selectedPrompt]);
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
if (selectedPrompt) {
requestCompletions(
{
type: "ref/prompt",
name: selectedPrompt.name,
},
argName,
value,
);
}
};
const handleGetPrompt = () => {
@@ -96,14 +127,17 @@ const PromptsTab = ({
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Input
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(e) =>
handleInputChange(arg.name, e.target.value)
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
@@ -118,11 +152,9 @@ const PromptsTab = ({
Get Prompt
</Button>
{promptContent && (
<Textarea
value={promptContent}
readOnly
className="h-64 font-mono"
/>
<div className="p-4 border rounded">
<JsonView data={promptContent} />
</div>
)}
</div>
) : (

View File

@@ -1,16 +1,21 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { TabsContent } from "@/components/ui/tabs";
import {
ListResourcesResult,
Resource,
ResourceTemplate,
ListResourceTemplatesResult,
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import ListPane from "./ListPane";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
const ResourcesTab = ({
resources,
@@ -22,6 +27,12 @@ const ResourcesTab = ({
readResource,
selectedResource,
setSelectedResource,
resourceSubscriptionsSupported,
resourceSubscriptions,
subscribeToResource,
unsubscribeFromResource,
handleCompletion,
completionsSupported,
resourceContent,
nextCursor,
nextTemplateCursor,
@@ -36,10 +47,20 @@ const ResourcesTab = ({
readResource: (uri: string) => void;
selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void;
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
resourceContent: string;
nextCursor: ListResourcesResult["nextCursor"];
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
error: string | null;
resourceSubscriptionsSupported: boolean;
resourceSubscriptions: Set<string>;
subscribeToResource: (uri: string) => void;
unsubscribeFromResource: (uri: string) => void;
}) => {
const [selectedTemplate, setSelectedTemplate] =
useState<ResourceTemplate | null>(null);
@@ -47,6 +68,13 @@ const ResourcesTab = ({
{},
);
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
useEffect(() => {
clearCompletions();
}, [clearCompletions]);
const fillTemplate = (
template: string,
values: Record<string, string>,
@@ -57,6 +85,21 @@ const ResourcesTab = ({
);
};
const handleTemplateValueChange = async (key: string, value: string) => {
setTemplateValues((prev) => ({ ...prev, [key]: value }));
if (selectedTemplate?.uriTemplate) {
requestCompletions(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
key,
value,
);
}
};
const handleReadTemplateResource = () => {
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
@@ -130,6 +173,29 @@ const ResourcesTab = ({
: "Select a resource or template"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -138,6 +204,7 @@ const ResourcesTab = ({
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
<div className="p-4">
@@ -148,9 +215,9 @@ const ResourcesTab = ({
<AlertDescription>{error}</AlertDescription>
</Alert>
) : 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">
{resourceContent}
</pre>
<div 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">
<JsonView data={resourceContent} />
</div>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
@@ -162,22 +229,18 @@ const ResourcesTab = ({
const key = param.slice(1, -1);
return (
<div key={key}>
<label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</label>
<Input
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(e) =>
setTemplateValues({
...templateValues,
[key]: e.target.value,
})
onChange={(value) =>
handleTemplateValueChange(key, value)
}
className="mt-1"
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);

View File

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

View File

@@ -1,5 +1,17 @@
import { useState } from "react";
import { Play, ChevronDown, ChevronRight } from "lucide-react";
import {
Play,
ChevronDown,
ChevronRight,
CircleHelp,
Bug,
Github,
Eye,
EyeOff,
RotateCcw,
Settings,
RefreshCwOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -10,6 +22,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import useTheme from "../lib/useTheme";
import { version } from "../../../package.json";
@@ -26,8 +43,16 @@ interface SidebarProps {
setSseUrl: (url: string) => void;
env: Record<string, string>;
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
}
const Sidebar = ({
@@ -42,11 +67,22 @@ const Sidebar = ({
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
onConnect,
onDisconnect,
stdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
config,
setConfig,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
@@ -86,6 +122,7 @@ const Sidebar = ({
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
@@ -94,18 +131,48 @@ const Sidebar = ({
placeholder="Arguments (space-separated)"
value={args}
onChange={(e) => setArgs(e.target.value)}
className="font-mono"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium">URL</label>
<Input
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label>
<Input
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
className="font-mono"
type="password"
/>
</div>
)}
</div>
</>
)}
{transportType === "stdio" && (
<div className="space-y-2">
@@ -124,19 +191,52 @@ const Sidebar = ({
{showEnvVars && (
<div className="space-y-2">
{Object.entries(env).map(([key, value], idx) => (
<div key={idx} className="grid grid-cols-[1fr,auto] gap-2">
<div className="space-y-1">
<div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2">
<Input
placeholder="Key"
value={key}
onChange={(e) => {
const newEnv = { ...env };
delete newEnv[key];
newEnv[e.target.value] = value;
const newKey = e.target.value;
const newEnv = Object.entries(env).reduce(
(acc, [k, v]) => {
if (k === key) {
acc[newKey] = value;
} else {
acc[k] = v;
}
return acc;
},
{} as Record<string, string>,
);
setEnv(newEnv);
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
next.add(newKey);
}
return next;
});
}}
className="font-mono"
/>
<Button
variant="destructive"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...rest } = env;
setEnv(rest);
}}
>
×
</Button>
</div>
<div className="flex gap-2">
<Input
type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value"
value={value}
onChange={(e) => {
@@ -144,25 +244,47 @@ const Sidebar = ({
newEnv[key] = e.target.value;
setEnv(newEnv);
}}
className="font-mono"
/>
</div>
<Button
variant="destructive"
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: removed, ...rest } = env;
setEnv(rest);
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}}
aria-label={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
aria-pressed={shownEnvVars.has(key)}
title={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
>
Remove
{shownEnvVars.has(key) ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
const key = "";
const newEnv = { ...env };
newEnv[""] = "";
newEnv[key] = "";
setEnv(newEnv);
}}
>
@@ -173,11 +295,107 @@ const Sidebar = ({
</div>
)}
{/* Configuration */}
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
>
{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>
{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">
<label className="text-sm font-medium">
{configItem.description}
</label>
{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 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
@@ -197,6 +415,28 @@ const Sidebar = ({
: "Disconnected"}
</span>
</div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
@@ -220,14 +460,14 @@ const Sidebar = ({
</div>
</div>
<div className="p-4 border-t">
<div className="flex items-center space-x-2">
<div className="flex items-center justify-between">
<Select
value={theme}
onValueChange={(value: string) =>
setTheme(value as "system" | "light" | "dark")
}
>
<SelectTrigger className="w-[120px]" id="theme-select">
<SelectTrigger className="w-[100px]" id="theme-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -236,6 +476,40 @@ const Sidebar = ({
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Button variant="ghost" title="Inspector Documentation" asChild>
<a
href="https://modelcontextprotocol.io/docs/tools/inspector"
target="_blank"
rel="noopener noreferrer"
>
<CircleHelp className="w-4 h-4 text-foreground" />
</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>

View File

@@ -1,19 +1,22 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult,
Tool,
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react";
import { useState } from "react";
import { Send } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
import JsonView from "./JsonView";
const ToolsTab = ({
tools,
@@ -24,19 +27,21 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
error,
}: {
tools: Tool[];
listTools: () => void;
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => void;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool) => void;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
useEffect(() => {
setParams({});
}, [selectedTool]);
const renderToolResult = () => {
if (!toolResult) return null;
@@ -47,17 +52,14 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult, null, 2)}
</pre>
<div className="p-4 border rounded">
<JsonView data={toolResult} />
</div>
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<pre
key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
>
{JSON.stringify(error, null, 2)}
</pre>
<div key={idx} className="p-4 border rounded">
<JsonView data={error} />
</div>
))}
</>
);
@@ -73,9 +75,9 @@ const ToolsTab = ({
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "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>
<div className="p-4 border rounded">
<JsonView data={item.text} />
</div>
)}
{item.type === "image" && (
<img
@@ -84,11 +86,20 @@ const ToolsTab = ({
className="max-w-full h-auto"
/>
)}
{item.type === "resource" && (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(item.resource, null, 2)}
</pre>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<div className="p-4 border rounded">
<JsonView data={item.resource} />
</div>
))}
</div>
))}
</>
@@ -97,9 +108,9 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult.toolResult, null, 2)}
</pre>
<div className="p-4 border rounded">
<JsonView data={toolResult.toolResult} />
</div>
</>
);
}
@@ -110,7 +121,10 @@ const ToolsTab = ({
<ListPane
items={tools}
listItems={listTools}
clearItems={clearTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
@@ -132,19 +146,15 @@ const ToolsTab = ({
</h3>
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedTool ? (
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => (
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
@@ -152,14 +162,32 @@ const ToolsTab = ({
>
{key}
</Label>
{
/* @ts-expect-error value type is currently unknown */
value.type === "string" ? (
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
// @ts-expect-error value type is currently unknown
placeholder={value.description}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
@@ -168,30 +196,54 @@ const ToolsTab = ({
}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : (
<Input
// @ts-expect-error value type is currently unknown
type={value.type === "number" ? "number" : "text"}
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key}
name={key}
// @ts-expect-error value type is currently unknown
placeholder={value.description}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]:
// @ts-expect-error value type is currently unknown
value.type === "number"
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
)
}
)}
</div>
),
);
},
)}
<Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" />

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,399 @@
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";
// 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(<Sidebar {...defaultProps} {...props} />);
};
const openEnvVarsSection = () => {
const button = screen.getByText("Environment Variables");
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(<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />);
// 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(<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />);
// 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.getByText("Configuration");
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({
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({
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(
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>,
);
// 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({
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

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,97 @@
import React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface ComboboxProps {
value: string;
onChange: (value: string) => void;
onInputChange: (value: string) => void;
options: string[];
placeholder?: string;
emptyMessage?: string;
id?: string;
}
export function Combobox({
value,
onChange,
onInputChange,
options = [],
placeholder = "Select...",
emptyMessage = "No results found.",
id,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = React.useCallback(
(option: string) => {
onChange(option);
setOpen(false);
},
[onChange],
);
const handleInputChange = React.useCallback(
(value: string) => {
onInputChange(value);
},
[onInputChange],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={id}
className="w-full justify-between"
>
{value || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} id={id}>
<CommandInput
placeholder={placeholder}
value={value}
onValueChange={handleInputChange}
/>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => handleSelect(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,150 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,121 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -57,6 +57,10 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
button[role="checkbox"] {
padding: 0;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;

73
client/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationSchema,
OAuthClientInformation,
OAuthTokens,
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS } from "./constants";
class InspectorOAuthClientProvider implements OAuthClientProvider {
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
get clientMetadata() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",
};
}
async clientInformation() {
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
}
saveClientInformation(clientInformation: OAuthClientInformation) {
sessionStorage.setItem(
SESSION_KEYS.CLIENT_INFORMATION,
JSON.stringify(clientInformation),
);
}
async tokens() {
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
if (!tokens) {
return undefined;
}
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
}
saveTokens(tokens: OAuthTokens) {
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
window.location.href = authorizationUrl.href;
}
saveCodeVerifier(codeVerifier: string) {
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
}
codeVerifier() {
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
}
export const authProvider = new InspectorOAuthClientProvider();

View File

@@ -0,0 +1,18 @@
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;
};

View File

@@ -0,0 +1,16 @@
import { InspectorConfig } from "./configurationTypes";
// OAuth-related session storage keys
export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
} as const;
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
},
} as const;

View File

@@ -0,0 +1,128 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
interface CompletionState {
completions: Record<string, string[]>;
loading: Record<string, boolean>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function useCompletionState(
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
) => Promise<string[]>,
completionsSupported: boolean = true,
debounceMs: number = 300,
) {
const [state, setState] = useState<CompletionState>({
completions: {},
loading: {},
});
const abortControllerRef = useRef<AbortController | null>(null);
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
const clearCompletions = useCallback(() => {
cleanup();
setState({
completions: {},
loading: {},
});
}, [cleanup]);
const requestCompletions = useCallback(
debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => {
if (!completionsSupported) {
return;
}
cleanup();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: true },
}));
try {
const values = await handleCompletion(
ref,
argName,
value,
abortController.signal,
);
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
completions: { ...prev.completions, [argName]: values },
loading: { ...prev.loading, [argName]: false },
}));
}
} catch (err) {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: false },
}));
}
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
}
},
debounceMs,
),
[handleCompletion, completionsSupported, cleanup, debounceMs],
);
// Clear completions when support status changes
useEffect(() => {
if (!completionsSupported) {
clearCompletions();
}
}, [completionsSupported, clearCompletions]);
return {
...state,
clearCompletions,
requestCompletions,
completionsSupported,
};
}

View File

@@ -0,0 +1,344 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request,
Result,
ServerCapabilities,
PromptReference,
ResourceReference,
McpError,
CompleteResultSchema,
ErrorCode,
CancelledNotificationSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { z } from "zod";
import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import packageJson from "../../../package.json";
interface UseConnectionOptions {
transportType: "stdio" | "sse";
command: string;
args: string;
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
}
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl,
bearerToken,
requestTimeout,
onNotification,
onStdErrNotification,
onPendingRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [completionsSupported, setCompletionsSupported] = useState(true);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
options?: RequestOptions,
): Promise<z.output<T>> => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, options?.timeout ?? requestTimeout);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: options?.signal ?? abortController.signal,
});
pushHistory(request, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
return response;
} catch (e: unknown) {
if (!options?.suppressToast) {
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
}
throw e;
}
};
const handleCompletion = async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
): Promise<string[]> => {
if (!mcpClient || !completionsSupported) {
return [];
}
const request: ClientRequest = {
method: "completion/complete",
params: {
argument: {
name: argName,
value,
},
ref,
},
};
try {
const response = await makeRequest(request, CompleteResultSchema, {
signal,
suppressToast: true,
});
return response?.completion.values || [];
} catch (e: unknown) {
// Disable completions silently if the server doesn't support them.
// See https://github.com/modelcontextprotocol/specification/discussions/122
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
setCompletionsSupported(false);
return [];
}
// Unexpected errors - show toast and rethrow
toast.error(e instanceof Error ? e.message : String(e));
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
const error = new Error("MCP client not connected");
toast.error(error.message);
throw error;
}
try {
await mcpClient.notification(notification);
// Log successful notifications
pushHistory(notification);
} catch (e: unknown) {
if (e instanceof McpError) {
// Log MCP protocol errors
pushHistory(notification, { error: e.message });
}
toast.error(e instanceof Error ? e.message : String(e));
throw e;
}
};
const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) {
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
const result = await auth(authProvider, { serverUrl: sseUrl });
return result === "AUTHORIZED";
}
return false;
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: packageJson.version,
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
},
},
);
const backendUrl = new URL(`${proxyServerUrl}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
// Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first.
const headers: HeadersInit = {};
// Use manually provided bearer token if available, otherwise use OAuth tokens
const token = bearerToken || (await authProvider.tokens())?.access_token;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const clientTransport = new SSEClientTransport(backendUrl, {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
});
if (onNotification) {
[
CancelledNotificationSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema,
ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
].forEach((notificationSchema) => {
client.setNotificationHandler(notificationSchema, onNotification);
});
client.fallbackNotificationHandler = (
notification: Notification,
): Promise<void> => {
onNotification(notification);
return Promise.resolve();
};
}
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
try {
await client.connect(clientTransport);
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);
}
if (error instanceof SseError && error.code === 401) {
// Don't set error state if we're about to redirect for auth
return;
}
throw error;
}
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
setCompletionsSupported(true); // Reset completions support on new connection
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
});
}
if (getRoots) {
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: getRoots() };
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
const disconnect = async () => {
await mcpClient?.close();
setMcpClient(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);
};
return {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest,
sendNotification,
handleCompletion,
completionsSupported,
connect,
disconnect,
};
}

View File

@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useDraggablePane(initialHeight: number) {
const [height, setHeight] = useState(initialHeight);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
document.body.style.userSelect = "none";
},
[height],
);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHeight(newHeight);
},
[isDragging],
);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
}
}, [isDragging, handleDragMove, handleDragEnd]);
return {
height,
isDragging,
handleDragStart,
};
}

View File

@@ -1,6 +1,7 @@
import {
NotificationSchema as BaseNotificationSchema,
ClientNotificationSchema,
ServerNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
@@ -13,7 +14,9 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema,
);
)
.or(ServerNotificationSchema)
.or(BaseNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
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";
@@ -36,16 +36,14 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
};
}, [theme]);
return [
theme,
useCallback((newTheme: Theme) => {
const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
if (newTheme !== "system") {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []),
];
}, []);
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
};
export default useTheme;

View File

@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import App from "./App.tsx";
import "./index.css";

View File

@@ -0,0 +1,27 @@
import { escapeUnicode } from "../escapeUnicode";
describe("escapeUnicode", () => {
it("should escape Unicode characters in a string", () => {
const input = { text: "你好世界" };
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle empty strings", () => {
const input = { text: "" };
const expected = '{\n "text": ""\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle null and undefined values", () => {
const input = { text: null, value: undefined };
const expected = '{\n "text": null\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle numbers and booleans", () => {
const input = { number: 123, boolean: true };
const expected = '{\n "number": 123,\n "boolean": true\n}';
expect(escapeUnicode(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,180 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
describe("updateValueAtPath", () => {
// Basic functionality tests
test("returns the new value when path is empty", () => {
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
test("updates a simple object property", () => {
const obj = { name: "John", age: 30 };
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
name: "John",
age: 31,
});
});
test("updates a nested object property", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
test("creates missing object properties", () => {
const obj = { user: { name: "John" } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
// Array update tests
test("updates an array item", () => {
const arr = [1, 2, 3, 4];
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
});
test("extends an array when index is out of bounds", () => {
const arr = [1, 2, 3];
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, 2, 3, null, null, "new"]);
// Explicitly verify that indices 3 and 4 contain null, not undefined
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
// Verify these aren't "holes" in the array (important distinction)
expect(3 in result).toBe(true);
expect(4 in result).toBe(true);
// Verify the array has the correct length
expect(result.length).toBe(6);
// Verify the array doesn't have holes by checking every index exists
expect(result.every((_, index: number) => index in result)).toBe(true);
});
test("updates a nested array item", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
users: [{ name: "John" }, { name: "Janet" }],
});
});
// Error handling tests
test("returns original value when trying to update a primitive with a path", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const result = updateValueAtPath("string", ["foo"], "bar");
expect(result).toBe("string");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is invalid", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is negative", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("handles sparse arrays correctly by filling holes with null", () => {
// Create a sparse array by deleting an element
const sparseArr = [1, 2, 3];
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
// Update a value beyond the array length
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, null, 3, null, null, "new"]);
// Explicitly verify that index 1 (the hole) contains null, not undefined
expect(result[1]).toBe(null);
// Verify this isn't a hole in the array
expect(1 in result).toBe(true);
// Verify all indices contain null (not undefined)
expect(result[1]).not.toBe(undefined);
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
});
});
describe("getValueAtPath", () => {
test("returns the original value when path is empty", () => {
const obj = { foo: "bar" };
expect(getValueAtPath(obj, [])).toBe(obj);
});
test("returns the value at a simple path", () => {
const obj = { name: "John", age: 30 };
expect(getValueAtPath(obj, ["name"])).toBe("John");
});
test("returns the value at a nested path", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
});
test("returns default value when path does not exist", () => {
const obj = { user: { name: "John" } };
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
"Unknown",
);
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
});
test("handles array indices correctly", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["1"])).toBe("b");
});
test("returns default value for out of bounds array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
});
test("returns default value for invalid array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
});
test("navigates through mixed object and array paths", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
});
});

View File

@@ -0,0 +1,139 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
null,
);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
},
};
expect(generateDefaultValue(schema)).toEqual({
name: "",
age: 0,
isActive: false,
});
});
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
user: {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
address: {
type: "object",
required: true,
properties: {
city: { type: "string", required: true },
},
},
},
},
},
};
expect(generateDefaultValue(schema)).toEqual({
user: {
name: "",
address: {
city: "",
},
},
});
});
test("uses schema default value when provided", () => {
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
"test",
);
});
});
describe("formatFieldLabel", () => {
test("formats camelCase", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");
});
test("formats snake_case", () => {
expect(formatFieldLabel("first_name")).toBe("First name");
});
test("formats single word", () => {
expect(formatFieldLabel("name")).toBe("Name");
});
test("formats mixed case with underscores", () => {
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
});
test("handles empty string", () => {
expect(formatFieldLabel("")).toBe("");
});
});

View File

@@ -0,0 +1,16 @@
// Utility function to escape Unicode characters
export function escapeUnicode(obj: unknown): string {
return JSON.stringify(
obj,
(_key: string, value) => {
if (typeof value === "string") {
// Replace non-ASCII characters with their Unicode escape sequences
return value.replace(/[^\0-\x7F]/g, (char) => {
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
});
}
return value;
},
2,
);
}

View File

@@ -0,0 +1,149 @@
import { JsonValue } from "../components/DynamicJsonForm";
export type JsonObject = { [key: string]: JsonValue };
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View File

@@ -0,0 +1,57 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @returns A default value matching the schema type, or null for non-required fields
*/
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
return schema.default;
}
if (!schema.required) {
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return null;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {};
Object.entries(schema.properties)
.filter(([, prop]) => prop.required)
.forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
});
return obj;
}
default:
return null;
}
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
* @returns A formatted label string
*/
export function formatFieldLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}

View File

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

10
client/tsconfig.jest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"jsx": "react-jsx",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "node"
},
"include": ["src"]
}

View File

@@ -1,10 +1,13 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -14,8 +17,8 @@ export default defineConfig({
minify: false,
rollupOptions: {
output: {
manualChunks: undefined
}
}
}
manualChunks: undefined,
},
},
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 385 KiB

4894
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.3.0",
"version": "0.8.0",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -22,6 +22,8 @@
],
"scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
"test": "npm run prettier-check && cd client && npm test",
"build-server": "cd server && npm run build",
"build-client": "cd client && npm run build",
"build": "npm run build-server && npm run build-client",
@@ -30,17 +32,21 @@
"start": "node ./bin/cli.js",
"prepare": "npm run build",
"prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .",
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "0.3.0",
"@modelcontextprotocol/inspector-server": "0.3.0",
"@modelcontextprotocol/inspector-client": "^0.8.0",
"@modelcontextprotocol/inspector-server": "^0.8.0",
"concurrently": "^9.0.1",
"spawn-rx": "^5.1.0",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5",
"prettier": "3.3.3"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.3.0",
"version": "0.8.0",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -16,20 +16,19 @@
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsx watch --clear-screen=false src/index.ts"
"dev": "tsx watch --clear-screen=false src/index.ts",
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/eventsource": "^1.1.15",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.12",
"tsx": "^4.19.0",
"typescript": "^5.6.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"@modelcontextprotocol/sdk": "^1.6.1",
"cors": "^2.8.5",
"eventsource": "^2.0.2",
"express": "^4.21.0",
"ws": "^8.18.0",
"zod": "^3.23.8"

View File

@@ -1,22 +1,29 @@
#!/usr/bin/env node
import cors from "cors";
import EventSource from "eventsource";
import { parseArgs } from "node:util";
import { parse as shellParseArgs } from "shell-quote";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
StdioClientTransport,
getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import mcpProxy from "./mcpProxy.js";
import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js";
// Polyfill EventSource for an SSE client in Node.js
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).EventSource = EventSource;
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const defaultEnvironment = {
...getDefaultEnvironment(),
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
};
const { values } = parseArgs({
args: process.argv.slice(2),
@@ -31,21 +38,21 @@ app.use(cors());
let webAppTransports: SSEServerTransport[] = [];
const createTransport = async (query: express.Request["query"]) => {
const createTransport = async (req: express.Request) => {
const query = req.query;
console.log("Query parameters:", query);
const transportType = query.transportType as string;
if (transportType === "stdio") {
const command = query.command as string;
const origArgs = (query.args as string).split(/\s+/);
const env = query.env ? JSON.parse(query.env as string) : undefined;
const origArgs = shellParseArgs(query.args as string) as string[];
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
const { cmd, args } = findActualExecutable(command, origArgs);
console.log(
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
);
console.log(`Stdio transport: command=${cmd}, args=${args}`);
const transport = new StdioClientTransport({
command: cmd,
@@ -60,9 +67,28 @@ const createTransport = async (query: express.Request["query"]) => {
return transport;
} else if (transportType === "sse") {
const url = query.url as string;
console.log(`SSE transport: url=${url}`);
const headers: HeadersInit = {
Accept: "text/event-stream",
};
for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
}
const transport = new SSEClientTransport(new URL(url));
const value = req.headers[key];
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
}
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
const transport = new SSEClientTransport(new URL(url), {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
});
await transport.start();
console.log("Connected to SSE transport");
@@ -73,11 +99,27 @@ const createTransport = async (query: express.Request["query"]) => {
}
};
let backingServerTransport: Transport | undefined;
app.get("/sse", async (req, res) => {
try {
console.log("New SSE connection");
const backingServerTransport = await createTransport(req.query);
try {
await backingServerTransport?.close();
backingServerTransport = await createTransport(req);
} catch (error) {
if (error instanceof SseError && error.code === 401) {
console.error(
"Received 401 Unauthorized from MCP server:",
error.message,
);
res.status(401).json(error);
return;
}
throw error;
}
console.log("Connected MCP client to backing server transport");
@@ -104,9 +146,6 @@ app.get("/sse", async (req, res) => {
mcpProxy({
transportToClient: webAppTransport,
transportToServer: backingServerTransport,
onerror: (error) => {
console.error(error);
},
});
console.log("Set up MCP proxy");
@@ -135,8 +174,6 @@ app.post("/message", async (req, res) => {
app.get("/config", (req, res) => {
try {
const defaultEnvironment = getDefaultEnvironment();
res.json({
defaultEnvironment,
defaultCommand: values.env,
@@ -148,5 +185,17 @@ app.get("/config", (req, res) => {
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {});
const PORT = process.env.PORT || 6277;
const server = app.listen(PORT);
server.on("listening", () => {
console.log(`⚙️ Proxy server listening on port ${PORT}`);
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT}`);
} else {
console.error(err.message);
}
process.exit(1);
});

View File

@@ -1,23 +1,29 @@
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
function onClientError(error: Error) {
console.error("Error from inspector client:", error);
}
function onServerError(error: Error) {
console.error("Error from MCP server:", error);
}
export default function mcpProxy({
transportToClient,
transportToServer,
onerror,
}: {
transportToClient: Transport;
transportToServer: Transport;
onerror: (error: Error) => void;
}) {
let transportToClientClosed = false;
let transportToServerClosed = false;
transportToClient.onmessage = (message) => {
transportToServer.send(message).catch(onerror);
transportToServer.send(message).catch(onServerError);
};
transportToServer.onmessage = (message) => {
transportToClient.send(message).catch(onerror);
transportToClient.send(message).catch(onClientError);
};
transportToClient.onclose = () => {
@@ -26,7 +32,7 @@ export default function mcpProxy({
}
transportToClientClosed = true;
transportToServer.close().catch(onerror);
transportToServer.close().catch(onServerError);
};
transportToServer.onclose = () => {
@@ -34,10 +40,9 @@ export default function mcpProxy({
return;
}
transportToServerClosed = true;
transportToClient.close().catch(onerror);
transportToClient.close().catch(onClientError);
};
transportToClient.onerror = onerror;
transportToServer.onerror = onerror;
transportToClient.onerror = onClientError;
transportToServer.onerror = onServerError;
}