Compare commits
569 Commits
davidsp/em
...
0.8.1-hotf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032a67d4e | ||
|
|
f0651baf4a | ||
|
|
3f73ec83a2 | ||
|
|
bdab26dbeb | ||
|
|
0f075af42c | ||
|
|
06fcc74638 | ||
|
|
9092c780f7 | ||
|
|
a75dd7ba1f | ||
|
|
a414033354 | ||
|
|
ce1a9d3905 | ||
|
|
0bd51fa84a | ||
|
|
2a544294ba | ||
|
|
897e637db4 | ||
|
|
5db5fc26c7 | ||
|
|
8b31f495ba | ||
|
|
c964ff5cfe | ||
|
|
e69bfc58bc | ||
|
|
debb00344a | ||
|
|
c9ee22b781 | ||
|
|
cc70fbd0f5 | ||
|
|
8586d63e6d | ||
|
|
539de0fd85 | ||
|
|
f9cb2c1bd0 | ||
|
|
80f2986fd6 | ||
|
|
1504d1307e | ||
|
|
d1e155f984 | ||
|
|
c48670f426 | ||
|
|
e9a90b9caf | ||
|
|
affd207c9e | ||
|
|
4afe2d6adb | ||
|
|
e4d4ff0148 | ||
|
|
d127ee86ce | ||
|
|
f7d39fb252 | ||
|
|
8faadc0588 | ||
|
|
0dcd10c1dd | ||
|
|
65f38a4827 | ||
|
|
51f2f72677 | ||
|
|
d2db697d89 | ||
|
|
93c9c74dc9 | ||
|
|
dada1c4ba6 | ||
|
|
c3117d0fea | ||
|
|
992fdb33ed | ||
|
|
2a9230d507 | ||
|
|
51c7eda6a6 | ||
|
|
b82c744583 | ||
|
|
9ab213bc89 | ||
|
|
2ee0a53e36 | ||
|
|
fa7f9c80cd | ||
|
|
7dc2c6fb58 | ||
|
|
f49b2d1442 | ||
|
|
d1746f53a4 | ||
|
|
b99cf276ae | ||
|
|
6c67bf6d6d | ||
|
|
80f5ab1136 | ||
|
|
2f854304a7 | ||
|
|
6b63bacbcd | ||
|
|
fb29ca0113 | ||
|
|
83ceefca79 | ||
|
|
c3bd1fb1c6 | ||
|
|
45d8202de8 | ||
|
|
180760c4db | ||
|
|
538fc97289 | ||
|
|
04442b52a2 | ||
|
|
7753b275e5 | ||
|
|
7ac1e40c9d | ||
|
|
d2696e48a5 | ||
|
|
7b055b6b9a | ||
|
|
da9dd09765 | ||
|
|
539f32bf3b | ||
|
|
c6174116b0 | ||
|
|
65fc6d0490 | ||
|
|
834eb0c934 | ||
|
|
054741be03 | ||
|
|
eaa8055fd1 | ||
|
|
9e1186f5ac | ||
|
|
81c0ef29ab | ||
|
|
75537c7cae | ||
|
|
dbaa731097 | ||
|
|
dd0434c4ed | ||
|
|
ed31f9893f | ||
|
|
38fb710f2f | ||
|
|
240c67037c | ||
|
|
02155570df | ||
|
|
7227909df3 | ||
|
|
84335ae5f4 | ||
|
|
8486696240 | ||
|
|
6a6b15ab45 | ||
|
|
804a144ffb | ||
|
|
1d4ca435b8 | ||
|
|
1b754f52ca | ||
|
|
e47b1d8f4d | ||
|
|
d2e211a597 | ||
|
|
205e70736c | ||
|
|
827d867aae | ||
|
|
da0c855ef5 | ||
|
|
8180b0bd6f | ||
|
|
f09d2b6096 | ||
|
|
f846c154f5 | ||
|
|
9244ecf859 | ||
|
|
0891077402 | ||
|
|
353d6b549b | ||
|
|
18ca6e28a7 | ||
|
|
2d252a389c | ||
|
|
e6f5da8383 | ||
|
|
196fd67ce9 | ||
|
|
4d4bb9110c | ||
|
|
0d17082480 | ||
|
|
cdf1f1508a | ||
|
|
b97233f43a | ||
|
|
885932ac70 | ||
|
|
00118f7cf9 | ||
|
|
806cdb204f | ||
|
|
fcc06ab556 | ||
|
|
03c1ba3092 | ||
|
|
029823bb92 | ||
|
|
2588f3aeb3 | ||
|
|
88ffb5087e | ||
|
|
1f17132ca1 | ||
|
|
c031831e71 | ||
|
|
0e667acf7d | ||
|
|
8e9e5facaf | ||
|
|
16b38071e7 | ||
|
|
25f5bb7620 | ||
|
|
731cee8511 | ||
|
|
6efdcb626f | ||
|
|
0656e15a22 | ||
|
|
30e7a4d7b7 | ||
|
|
d204dd6e7e | ||
|
|
f2f209dbd3 | ||
|
|
f0b28d4760 | ||
|
|
65a0d46816 | ||
|
|
951db44bad | ||
|
|
379486b5ea | ||
|
|
40213bb1ed | ||
|
|
f8b7b88a25 | ||
|
|
b7fa23676a | ||
|
|
a7f25153c4 | ||
|
|
fa3e2867c9 | ||
|
|
5735f2347a | ||
|
|
cab1ed3dd8 | ||
|
|
61e229a552 | ||
|
|
451704471c | ||
|
|
668cc915e4 | ||
|
|
85f0e21679 | ||
|
|
fc76a7c7d4 | ||
|
|
210975e385 | ||
|
|
ec73831487 | ||
|
|
9b0da1f892 | ||
|
|
3ac00598ff | ||
|
|
484e2820bc | ||
|
|
2890e036ed | ||
|
|
008204fda5 | ||
|
|
af44efb236 | ||
|
|
51f3135c76 | ||
|
|
4a23585066 | ||
|
|
3488bdb613 | ||
|
|
043f6040c6 | ||
|
|
74d0fcf5a3 | ||
|
|
de8795106c | ||
|
|
c726c53b00 | ||
|
|
20db043b40 | ||
|
|
dcbd1dad41 | ||
|
|
dfc9cf7629 | ||
|
|
4fdbcee706 | ||
|
|
ce81fb976b | ||
|
|
029e482e05 | ||
|
|
27b54104c1 | ||
|
|
536b7e0a99 | ||
|
|
c463dc58c2 | ||
|
|
a85d5e7050 | ||
|
|
7ddba51b36 | ||
|
|
dd460bd877 | ||
|
|
50131c6960 | ||
|
|
28978ea24f | ||
|
|
cae7c76358 | ||
|
|
50a65d0c7a | ||
|
|
e1b015e40d | ||
|
|
7c4ed6abca | ||
|
|
a3740c4798 | ||
|
|
d8b5bdb613 | ||
|
|
5104952239 | ||
|
|
67722aea71 | ||
|
|
cedf02d152 | ||
|
|
f01f02d5be | ||
|
|
56932e8a93 | ||
|
|
aeaf32fa45 | ||
|
|
090b7efdea | ||
|
|
cda3905e5a | ||
|
|
fb667fd4d0 | ||
|
|
d70e6dc0e8 | ||
|
|
1f214deeab | ||
|
|
c77252900a | ||
|
|
498c02b0f1 | ||
|
|
60c4645eaf | ||
|
|
fe8b1ee88b | ||
|
|
04a90e8d89 | ||
|
|
e5ee00bf89 | ||
|
|
397a0f651f | ||
|
|
0281e5f821 | ||
|
|
f56961ac62 | ||
|
|
15bbb7502b | ||
|
|
7caf6f8ba8 | ||
|
|
dbd616905c | ||
|
|
1ff410ca3d | ||
|
|
35a0f4611a | ||
|
|
952bee2605 | ||
|
|
a669272fda | ||
|
|
747c0154c5 | ||
|
|
0870a81990 | ||
|
|
ca18faa7c3 | ||
|
|
014730fb2f | ||
|
|
9c690e004b | ||
|
|
b9b116a5f2 | ||
|
|
4efe7d7899 | ||
|
|
027eb02422 | ||
|
|
b116264f90 | ||
|
|
290d5ab49e | ||
|
|
826ce37d2c | ||
|
|
7a56a7200c | ||
|
|
1eba99c531 | ||
|
|
00836dbf9e | ||
|
|
dd02b69036 | ||
|
|
f9b105c0ef | ||
|
|
1ae77e9ef8 | ||
|
|
13ae2b5659 | ||
|
|
06773bb6dd | ||
|
|
b01e386659 | ||
|
|
e7f55f083f | ||
|
|
36aa7316ea | ||
|
|
0e50b68f96 | ||
|
|
a1eb343b79 | ||
|
|
82bbe58a46 | ||
|
|
44982e6c97 | ||
|
|
6ec82e21b1 | ||
|
|
abd4877dae | ||
|
|
d1f5b3b933 | ||
|
|
720480cbbb | ||
|
|
8ac7ef0985 | ||
|
|
238c22830b | ||
|
|
426fb87640 | ||
|
|
90ce628040 | ||
|
|
d4a64fb5d8 | ||
|
|
ede1ea0faa | ||
|
|
0747479694 | ||
|
|
db1b5cbc45 | ||
|
|
0b105b29c1 | ||
|
|
0e29e2c1cf | ||
|
|
989efb2204 | ||
|
|
592dacad39 | ||
|
|
717c394d3b | ||
|
|
8267e514ce | ||
|
|
18438dbdd0 | ||
|
|
07577fc94b | ||
|
|
88984c7bc7 | ||
|
|
b4870b3da3 | ||
|
|
19ee9fa86a | ||
|
|
e28a64c932 | ||
|
|
02479d3ea9 | ||
|
|
c3ece186a4 | ||
|
|
4201b31a24 | ||
|
|
50638806cb | ||
|
|
27880974a2 | ||
|
|
266e8bec98 | ||
|
|
ed59974d65 | ||
|
|
8e06165d73 | ||
|
|
e22d3c76bf | ||
|
|
02f53005db | ||
|
|
3893807841 | ||
|
|
7b40aed43b | ||
|
|
0f304a37ae | ||
|
|
39970db604 | ||
|
|
f505ae3d5a | ||
|
|
89ee2b1b93 | ||
|
|
450405733a | ||
|
|
d4df126112 | ||
|
|
7065d70e34 | ||
|
|
ad004bc2f7 | ||
|
|
db6494353c | ||
|
|
3408be3e55 | ||
|
|
406828ade2 | ||
|
|
44d07b964c | ||
|
|
5b2d54ae3b | ||
|
|
f7312ab331 | ||
|
|
59e7639d39 | ||
|
|
133b785f79 | ||
|
|
f6860a88f9 | ||
|
|
b3194ac56e | ||
|
|
7c57e823bd | ||
|
|
b8e73886dd | ||
|
|
2a1536d2ab | ||
|
|
348cff9872 | ||
|
|
beee38387c | ||
|
|
7b3dff68c0 | ||
|
|
d9df5ff860 | ||
|
|
5b451a7cfe | ||
|
|
7f713fe40e | ||
|
|
fa723abbe0 | ||
|
|
410a6f33dc | ||
|
|
b324378b2c | ||
|
|
e427f7bca5 | ||
|
|
c66feff37d | ||
|
|
9b624e8c87 | ||
|
|
ba99638f48 | ||
|
|
f4aefa2706 | ||
|
|
e9a50adde7 | ||
|
|
eb6af47b21 | ||
|
|
6d930ecae7 | ||
|
|
9c3fee1442 | ||
|
|
688752ea77 | ||
|
|
1b13b574f8 | ||
|
|
95bbd60a38 | ||
|
|
96ba6fd531 | ||
|
|
8592cf2d07 | ||
|
|
dd47b574b3 | ||
|
|
b4ae1327b5 | ||
|
|
b5762d53fd | ||
|
|
7957d9f577 | ||
|
|
4c89aed4d9 | ||
|
|
79547143a8 | ||
|
|
d438760e36 | ||
|
|
d0ad677784 | ||
|
|
98b26e9d06 | ||
|
|
d007f92302 | ||
|
|
6a3d901a72 | ||
|
|
58ad8103f7 | ||
|
|
ee2c67e1af | ||
|
|
7c89a01c99 | ||
|
|
fb3d89c6e3 | ||
|
|
4b3bb5f34e | ||
|
|
1d4e8885db | ||
|
|
a87bd17f51 | ||
|
|
afe14bc883 | ||
|
|
04faff4757 | ||
|
|
a4469f7895 | ||
|
|
f980763381 | ||
|
|
d754395a9a | ||
|
|
df955cfdb5 | ||
|
|
5b884b55b5 | ||
|
|
0882a3e0e5 | ||
|
|
fce6644e30 | ||
|
|
51ea4bc6ac | ||
|
|
0648ba44e3 | ||
|
|
c22f91858c | ||
|
|
99d7592ac9 | ||
|
|
3bc776f7cd | ||
|
|
a6d22cf1e4 | ||
|
|
731ee588c2 | ||
|
|
af8877064e | ||
|
|
874320ebe6 | ||
|
|
e470eb5c51 | ||
|
|
02cfb47c83 | ||
|
|
23f89e49b8 | ||
|
|
16cb59670c | ||
|
|
1c4ad60354 | ||
|
|
8a20f7711a | ||
|
|
8bb5308797 | ||
|
|
14db05c2a2 | ||
|
|
e7697eb5cd | ||
|
|
c1e06c4af0 | ||
|
|
60b8892dd3 | ||
|
|
2b53a8399c | ||
|
|
361f9d109b | ||
|
|
7ec661e8bd | ||
|
|
f8052dfcda | ||
|
|
98e6f0e5ec | ||
|
|
ec150eb8b4 | ||
|
|
052de8690d | ||
|
|
a976aefb39 | ||
|
|
5a5873277c | ||
|
|
715936d747 | ||
|
|
d973f58bef | ||
|
|
88ed9c088d | ||
|
|
1797fbfba8 | ||
|
|
8f4013c42c | ||
|
|
1abb7ca59c | ||
|
|
dfb36e1792 | ||
|
|
ffc29663c8 | ||
|
|
53226dd391 | ||
|
|
243ee1a6b5 | ||
|
|
dc49d46baa | ||
|
|
ef32a8f289 | ||
|
|
54e9957ec5 | ||
|
|
c78b0fbed6 | ||
|
|
7edde5001b | ||
|
|
0fa56e14d9 | ||
|
|
14bda1f030 | ||
|
|
1f4d35f8a3 | ||
|
|
eb70539958 | ||
|
|
7878e1764a | ||
|
|
26f0cb3c8b | ||
|
|
8f40e052c1 | ||
|
|
024f06c1b7 | ||
|
|
1ddc63b330 | ||
|
|
27bd503240 | ||
|
|
b39c96de7c | ||
|
|
d857e1462b | ||
|
|
2eae823d65 | ||
|
|
79ba164fda | ||
|
|
2f513df6c1 | ||
|
|
ce68085e77 | ||
|
|
e96b3be159 | ||
|
|
034699524a | ||
|
|
fdc521646f | ||
|
|
bd6586bbad | ||
|
|
c340e5f1ed | ||
|
|
cc1ae05f9d | ||
|
|
9ea77a729c | ||
|
|
8c7b0c360e | ||
|
|
35effc4d16 | ||
|
|
576ff0043a | ||
|
|
18dc4d0a99 | ||
|
|
ed5017d73e | ||
|
|
f04b161411 | ||
|
|
bd6a63603a | ||
|
|
b845444fab | ||
|
|
14802b8043 | ||
|
|
ace94c4d37 | ||
|
|
50640bc9cc | ||
|
|
068d21387a | ||
|
|
66b1b73448 | ||
|
|
cc17ba8d56 | ||
|
|
764f02310d | ||
|
|
945299181d | ||
|
|
79344bd495 | ||
|
|
295ccac27e | ||
|
|
f3f424f21e | ||
|
|
6b6eeb8dcd | ||
|
|
3110cf9343 | ||
|
|
2c04fa31e8 | ||
|
|
e700bc713a | ||
|
|
bea86af65b | ||
|
|
68a6130b17 | ||
|
|
853a3b4faf | ||
|
|
6f62066d34 | ||
|
|
c770d217e7 | ||
|
|
98470a12f9 | ||
|
|
a00564fafa | ||
|
|
62546dec58 | ||
|
|
886ac5fc7b | ||
|
|
722df4d798 | ||
|
|
407e304585 | ||
|
|
60578314aa | ||
|
|
3c4cb17d09 | ||
|
|
fbac5b78bc | ||
|
|
f876b1ec0d | ||
|
|
aecfa21d47 | ||
|
|
a3d542c0a3 | ||
|
|
2b79b6ffd4 | ||
|
|
1f28b4474c | ||
|
|
d69d67cb64 | ||
|
|
7792070d81 | ||
|
|
34a2843756 | ||
|
|
2a34770959 | ||
|
|
6b674b0827 | ||
|
|
ca8db1f417 | ||
|
|
eb4456d1e3 | ||
|
|
780b92274d | ||
|
|
b825784b8f | ||
|
|
52c7e98055 | ||
|
|
4862aa7c1d | ||
|
|
561ea91504 | ||
|
|
7c2be8d139 | ||
|
|
97d469911e | ||
|
|
11b891c6ca | ||
|
|
5139e723a4 | ||
|
|
fc5b79c9a6 | ||
|
|
47a87e1884 | ||
|
|
d567ff37e8 | ||
|
|
2e4eedc6ef | ||
|
|
56ec9befd9 | ||
|
|
360d090ac9 | ||
|
|
fda05836cb | ||
|
|
dac692e638 | ||
|
|
7e4b276f7f | ||
|
|
668448b047 | ||
|
|
4352c93660 | ||
|
|
22bf78720b | ||
|
|
9196c1ddaf | ||
|
|
dfc10488b2 | ||
|
|
78182eab10 | ||
|
|
7dbaee47a2 | ||
|
|
03193a9dc4 | ||
|
|
60dea9a868 | ||
|
|
1341d73775 | ||
|
|
f73770b143 | ||
|
|
33ab8dbd97 | ||
|
|
2d5c866f82 | ||
|
|
232d5ffcf6 | ||
|
|
4f930a61ab | ||
|
|
f684d2e891 | ||
|
|
676de45bab | ||
|
|
9a560e3f06 | ||
|
|
64d2fea0f1 | ||
|
|
1843562dce | ||
|
|
31630b2870 | ||
|
|
7c04df5e2e | ||
|
|
f8e26479d9 | ||
|
|
0c6b1ad1d2 | ||
|
|
b97acb9b76 | ||
|
|
1b06e50203 | ||
|
|
83abe4e00f | ||
|
|
75bc2d3f26 | ||
|
|
b127ba177a | ||
|
|
ce350433f9 | ||
|
|
c1a56810fb | ||
|
|
abff2486c1 | ||
|
|
b096f3f991 | ||
|
|
e97ec8dbc7 | ||
|
|
05c22f6367 | ||
|
|
3b3a7e6015 | ||
|
|
8986c32bb4 | ||
|
|
69b25300da | ||
|
|
6e72c7583e | ||
|
|
d1c9acffc2 | ||
|
|
d91d7e8ae0 | ||
|
|
6c27f5a263 | ||
|
|
2bf84a3ef3 | ||
|
|
8aad8b4aac | ||
|
|
06267d28f4 | ||
|
|
c1c8fc2f42 | ||
|
|
7a350785fe | ||
|
|
41f8ec0868 | ||
|
|
09d66ab704 | ||
|
|
e4faa19acb | ||
|
|
f7385dd961 | ||
|
|
54012aca6a | ||
|
|
0cf344bb6a | ||
|
|
a507bafc3e | ||
|
|
93b1ec4d61 | ||
|
|
bf2ddc9b7b | ||
|
|
da2ac8d423 | ||
|
|
3bae26723a | ||
|
|
9d0c643926 | ||
|
|
0716adafc6 | ||
|
|
733d2a6e6e | ||
|
|
ab9c130610 | ||
|
|
3e46011614 | ||
|
|
584c1076a4 | ||
|
|
de9ee3956e | ||
|
|
01fb48e6ef | ||
|
|
5ceaa48cf3 | ||
|
|
6fdb2903a4 | ||
|
|
b9d6695fbf | ||
|
|
3789ef984a | ||
|
|
e1aa419332 | ||
|
|
94e4e92618 | ||
|
|
148e84388a | ||
|
|
6147640656 | ||
|
|
1ddd2d7aaa | ||
|
|
94ddfed1ac | ||
|
|
448910d986 | ||
|
|
3a9b08bd37 | ||
|
|
bce3a7b8d6 | ||
|
|
196f2f801d | ||
|
|
a0d8ec1e7e | ||
|
|
6759c461f9 | ||
|
|
ea4484cc04 | ||
|
|
171b59bec6 | ||
|
|
2867173e7b | ||
|
|
57f0c49154 | ||
|
|
5337baa116 | ||
|
|
afefcb3fa5 | ||
|
|
76e2cf6fdc | ||
|
|
193032533b | ||
|
|
f3406ca43d | ||
|
|
645f2e942e | ||
|
|
d80214d0a2 | ||
|
|
97b67ca1ae | ||
|
|
216c8a15d5 |
47
.github/workflows/main.yml
vendored
47
.github/workflows/main.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -12,26 +14,47 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx prettier --check .
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
environment: release
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
repository: modelcontextprotocol/typescript-sdk
|
node-version: 18
|
||||||
ssh-key: ${{ secrets.TYPESCRIPT_SDK_KEY }}
|
cache: npm
|
||||||
path: packages/@modelcontextprotocol/sdk
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- run: npm ci
|
|
||||||
working-directory: packages/@modelcontextprotocol/sdk
|
|
||||||
|
|
||||||
- run: npm pack
|
|
||||||
working-directory: packages/@modelcontextprotocol/sdk
|
|
||||||
|
|
||||||
- run: npm install --save packages/@modelcontextprotocol/sdk/modelcontextprotocol-sdk-*.tgz
|
|
||||||
|
|
||||||
# Working around https://github.com/npm/cli/issues/4828
|
# Working around https://github.com/npm/cli/issues/4828
|
||||||
# - run: npm ci
|
# - run: npm ci
|
||||||
- run: npm install --no-package-lock
|
- run: npm install --no-package-lock
|
||||||
- run: npm run build
|
|
||||||
|
# TODO: Add --provenance once the repo is public
|
||||||
|
- run: npm run publish-all
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ server/build
|
|||||||
client/dist
|
client/dist
|
||||||
client/tsconfig.app.tsbuildinfo
|
client/tsconfig.app.tsbuildinfo
|
||||||
client/tsconfig.node.tsbuildinfo
|
client/tsconfig.node.tsbuildinfo
|
||||||
|
.vscode
|
||||||
|
|||||||
3
.npmrc
3
.npmrc
@@ -1 +1,2 @@
|
|||||||
registry = "https://registry.npmjs.org/"
|
registry="https://registry.npmjs.org/"
|
||||||
|
@modelcontextprotocol:registry="https://registry.npmjs.org/"
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
packages
|
packages
|
||||||
server/build
|
server/build
|
||||||
|
CODE_OF_CONDUCT.md
|
||||||
|
SECURITY.md
|
||||||
|
|||||||
33
CLAUDE.md
Normal file
33
CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# MCP Inspector Development Guide
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
- Build all: `npm run build`
|
||||||
|
- Build client: `npm run build-client`
|
||||||
|
- Build server: `npm run build-server`
|
||||||
|
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
|
||||||
|
- Format code: `npm run prettier-fix`
|
||||||
|
- Client lint: `cd client && npm run lint`
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript with proper type annotations
|
||||||
|
- Follow React functional component patterns with hooks
|
||||||
|
- Use ES modules (import/export) not CommonJS
|
||||||
|
- Use Prettier for formatting (auto-formatted on commit)
|
||||||
|
- Follow existing naming conventions:
|
||||||
|
- camelCase for variables and functions
|
||||||
|
- PascalCase for component names and types
|
||||||
|
- kebab-case for file names
|
||||||
|
- Use async/await for asynchronous operations
|
||||||
|
- Implement proper error handling with try/catch blocks
|
||||||
|
- Use Tailwind CSS for styling in the client
|
||||||
|
- Keep components small and focused on a single responsibility
|
||||||
|
|
||||||
|
## Project Organization
|
||||||
|
|
||||||
|
The project is organized as a monorepo with workspaces:
|
||||||
|
|
||||||
|
- `client/`: React frontend with Vite, TypeScript and Tailwind
|
||||||
|
- `server/`: Express backend with TypeScript
|
||||||
|
- `bin/`: CLI scripts
|
||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
mcp-coc@anthropic.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
37
CONTRIBUTING.md
Normal file
37
CONTRIBUTING.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Contributing to Model Context Protocol Inspector
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This guide explains how to get involved.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
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://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. 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
|
||||||
|
7. Submit a pull request
|
||||||
|
8. PRs will be reviewed by maintainers
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
If you find a security vulnerability, please refer to our [Security Policy](SECURITY.md) for reporting instructions.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the MIT license.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Anthropic, PBC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
73
README.md
73
README.md
@@ -2,30 +2,83 @@
|
|||||||
|
|
||||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||
## Getting started
|

|
||||||
|
|
||||||
This repository depends on the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk/). Until these repositories are made public and published to npm, the SDK has to be preinstalled manually:
|
## Running the Inspector
|
||||||
|
|
||||||
1. Download the [latest release of the SDK](https://github.com/modelcontextprotocol/typescript-sdk/releases) (the file named something like `modelcontextprotocol-sdk-0.1.0.tgz`). You don't need to extract it.
|
### From an MCP server repository
|
||||||
2. From within your checkout of _this_ repository, run `npm install --save path/to/sdk.tgz`. This will overwrite the expected location for the SDK to allow you to proceed.
|
|
||||||
|
|
||||||
Then, you should be able to install the rest of the dependencies normally:
|
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`:
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
npm install
|
npx @modelcontextprotocol/inspector node build/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
You can run it in dev mode via:
|
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pass arguments only
|
||||||
|
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
|
||||||
|
|
||||||
|
# Pass environment variables only
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
|
||||||
|
|
||||||
|
# Pass both environment variables and arguments
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
|
||||||
|
|
||||||
|
# Use -- to separate inspector flags from server arguments
|
||||||
|
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
|
||||||
|
```
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` |
|
||||||
|
|
||||||
|
### From this repository
|
||||||
|
|
||||||
|
If you're working on the inspector itself:
|
||||||
|
|
||||||
|
Development mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start both the client and server.
|
> **Note for Windows users:**
|
||||||
|
> On Windows, use the following command instead:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> npm run dev:windows
|
||||||
|
> ```
|
||||||
|
|
||||||
To run in production mode:
|
Production mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
14
SECURITY.md
Normal file
14
SECURITY.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Security Policy
|
||||||
|
Thank you for helping us keep the inspector secure.
|
||||||
|
|
||||||
|
## Reporting Security Issues
|
||||||
|
|
||||||
|
This project is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project.
|
||||||
|
|
||||||
|
The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
|
||||||
|
|
||||||
|
Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability).
|
||||||
|
|
||||||
|
## Vulnerability Disclosure Program
|
||||||
|
|
||||||
|
Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp).
|
||||||
120
bin/cli.js
Executable file
120
bin/cli.js
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { spawnPromise } from "spawn-rx";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 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,
|
||||||
|
"..",
|
||||||
|
"server",
|
||||||
|
"build",
|
||||||
|
"index.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Path to the client entry point
|
||||||
|
const inspectorClientPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"client",
|
||||||
|
"bin",
|
||||||
|
"cli.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||||
|
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
|
||||||
|
|
||||||
|
console.log("Starting MCP inspector...");
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
|
});
|
||||||
|
let server, serverOk;
|
||||||
|
try {
|
||||||
|
server = spawnPromise(
|
||||||
|
"node",
|
||||||
|
[
|
||||||
|
inspectorServerPath,
|
||||||
|
...(command ? [`--env`, command] : []),
|
||||||
|
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: SERVER_PORT,
|
||||||
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
|
},
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure server started before starting client
|
||||||
|
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
if (serverOk) {
|
||||||
|
try {
|
||||||
|
await spawnPromise("node", [inspectorClientPath], {
|
||||||
|
env: { ...process.env, PORT: CLIENT_PORT },
|
||||||
|
signal: abort.signal,
|
||||||
|
echoOutput: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled || process.env.DEBUG) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then((_) => process.exit(0))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
33
client/bin/cli.js
Executable file
33
client/bin/cli.js
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import handler from "serve-handler";
|
||||||
|
import http from "http";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distPath = join(__dirname, "../dist");
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
return handler(request, response, {
|
||||||
|
public: distPath,
|
||||||
|
rewrites: [{ source: "/**", destination: "/index.html" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/mcp.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MCP Inspector</title>
|
<title>MCP Inspector</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
33
client/jest.config.cjs
Normal file
33
client/jest.config.cjs
Normal 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)$",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,43 +1,75 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"private": true,
|
"version": "0.8.1",
|
||||||
"version": "0.0.0",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
"homepage": "https://modelcontextprotocol.io",
|
||||||
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcp-inspector-client": "./bin/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview --port 6274",
|
||||||
|
"test": "jest --config jest.config.cjs",
|
||||||
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "*",
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.4",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
|
"pkce-challenge": "^4.1.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"serve-handler": "^6.1.6",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/serve-handler": "^6.1.4",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"co": "^4.6.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"ts-jest": "^29.2.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ export default {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
12
client/public/mcp.svg
Normal file
12
client/public/mcp.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_19_13)">
|
||||||
|
<path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_19_13">
|
||||||
|
<rect width="180" height="180" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 973 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,86 +1,113 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
|
||||||
ClientRequest,
|
ClientRequest,
|
||||||
CreateMessageRequestSchema,
|
CompatibilityCallToolResult,
|
||||||
|
CompatibilityCallToolResultSchema,
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
EmptyResultSchema,
|
EmptyResultSchema,
|
||||||
GetPromptResultSchema,
|
GetPromptResultSchema,
|
||||||
ListPromptsResultSchema,
|
ListPromptsResultSchema,
|
||||||
ListResourcesResultSchema,
|
ListResourcesResultSchema,
|
||||||
|
ListResourceTemplatesResultSchema,
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
ProgressNotificationSchema,
|
|
||||||
ReadResourceResultSchema,
|
ReadResourceResultSchema,
|
||||||
Resource,
|
Resource,
|
||||||
|
ResourceTemplate,
|
||||||
|
Root,
|
||||||
ServerNotification,
|
ServerNotification,
|
||||||
Tool,
|
Tool,
|
||||||
|
LoggingLevel,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
|
import { useConnection } from "./lib/hooks/useConnection";
|
||||||
|
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||||
|
import { StdErrNotification } from "./lib/notificationTypes";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Files,
|
Files,
|
||||||
|
FolderTree,
|
||||||
Hammer,
|
Hammer,
|
||||||
Hash,
|
Hash,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Play,
|
|
||||||
Send,
|
|
||||||
Terminal,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { AnyZodObject } from "zod";
|
import { z } from "zod";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
import HistoryAndNotifications from "./components/History";
|
import HistoryAndNotifications from "./components/History";
|
||||||
import PingTab from "./components/PingTab";
|
import PingTab from "./components/PingTab";
|
||||||
import PromptsTab, { Prompt } from "./components/PromptsTab";
|
import PromptsTab, { Prompt } from "./components/PromptsTab";
|
||||||
import RequestsTab from "./components/RequestsTabs";
|
|
||||||
import ResourcesTab from "./components/ResourcesTab";
|
import ResourcesTab from "./components/ResourcesTab";
|
||||||
|
import RootsTab from "./components/RootsTab";
|
||||||
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import ToolsTab from "./components/ToolsTab";
|
import ToolsTab from "./components/ToolsTab";
|
||||||
|
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
|
||||||
|
import { InspectorConfig } from "./lib/configurationTypes";
|
||||||
|
import {
|
||||||
|
getMCPProxyAddress,
|
||||||
|
getMCPServerRequestTimeout,
|
||||||
|
} from "./utils/configUtils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [connectionStatus, setConnectionStatus] = useState<
|
const { toast } = useToast();
|
||||||
"disconnected" | "connected" | "error"
|
// Handle OAuth callback route
|
||||||
>("disconnected");
|
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
|
ResourceTemplate[]
|
||||||
|
>([]);
|
||||||
const [resourceContent, setResourceContent] = useState<string>("");
|
const [resourceContent, setResourceContent] = useState<string>("");
|
||||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||||
const [promptContent, setPromptContent] = useState<string>("");
|
const [promptContent, setPromptContent] = useState<string>("");
|
||||||
const [tools, setTools] = useState<Tool[]>([]);
|
const [tools, setTools] = useState<Tool[]>([]);
|
||||||
const [toolResult, setToolResult] = useState<string>("");
|
const [toolResult, setToolResult] =
|
||||||
const [error, setError] = useState<string | null>(null);
|
useState<CompatibilityCallToolResult | null>(null);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | null>>({
|
||||||
|
resources: null,
|
||||||
|
prompts: null,
|
||||||
|
tools: null,
|
||||||
|
});
|
||||||
const [command, setCommand] = useState<string>(() => {
|
const [command, setCommand] = useState<string>(() => {
|
||||||
return (
|
return localStorage.getItem("lastCommand") || "mcp-server-everything";
|
||||||
localStorage.getItem("lastCommand") ||
|
|
||||||
"/Users/ashwin/.nvm/versions/node/v18.20.4/bin/node"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
const [args, setArgs] = useState<string>(() => {
|
const [args, setArgs] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastArgs") || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sseUrl, setSseUrl] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
|
||||||
|
});
|
||||||
|
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
|
||||||
return (
|
return (
|
||||||
localStorage.getItem("lastArgs") ||
|
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
||||||
"/Users/ashwin/code/mcp/example-servers/build/everything/stdio.js"
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const [url, setUrl] = useState<string>("http://localhost:3001/sse");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
|
||||||
const [requestHistory, setRequestHistory] = useState<
|
|
||||||
{ request: string; response: string }[]
|
|
||||||
>([]);
|
|
||||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
|
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||||
|
StdErrNotification[]
|
||||||
|
>([]);
|
||||||
|
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);
|
||||||
|
if (savedConfig) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
...JSON.parse(savedConfig),
|
||||||
|
} as InspectorConfig;
|
||||||
|
}
|
||||||
|
return DEFAULT_INSPECTOR_CONFIG;
|
||||||
|
});
|
||||||
|
const [bearerToken, setBearerToken] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastBearerToken") || "";
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
@@ -91,6 +118,146 @@ const App = () => {
|
|||||||
>
|
>
|
||||||
>([]);
|
>([]);
|
||||||
const nextRequestId = useRef(0);
|
const nextRequestId = useRef(0);
|
||||||
|
const rootsRef = useRef<Root[]>([]);
|
||||||
|
|
||||||
|
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<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
const [nextPromptCursor, setNextPromptCursor] = useState<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||||
|
const progressTokenRef = useRef(0);
|
||||||
|
|
||||||
|
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: getMCPProxyAddress(config),
|
||||||
|
requestTimeout: getMCPServerRequestTimeout(config),
|
||||||
|
onNotification: (notification) => {
|
||||||
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
|
},
|
||||||
|
onStdErrNotification: (notification) => {
|
||||||
|
setStdErrNotifications((prev) => [
|
||||||
|
...prev,
|
||||||
|
notification as StdErrNotification,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onPendingRequest: (request, resolve, reject) => {
|
||||||
|
setPendingSampleRequests((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: nextRequestId.current++, request, resolve, reject },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
getRoots: () => rootsRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastCommand", command);
|
||||||
|
}, [command]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const hasProcessedRef = useRef(false);
|
||||||
|
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasProcessedRef.current) {
|
||||||
|
// Only try to connect once
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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({
|
||||||
|
title: "Success",
|
||||||
|
description: "Successfully authenticated with OAuth",
|
||||||
|
});
|
||||||
|
hasProcessedRef.current = true;
|
||||||
|
// Connect to the server
|
||||||
|
connectMcpServer();
|
||||||
|
}
|
||||||
|
}, [connectMcpServer, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
setEnv(data.defaultEnvironment);
|
||||||
|
if (data.defaultCommand) {
|
||||||
|
setCommand(data.defaultCommand);
|
||||||
|
}
|
||||||
|
if (data.defaultArgs) {
|
||||||
|
setArgs(data.defaultArgs);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
console.error("Error fetching default environment:", error),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rootsRef.current = roots;
|
||||||
|
}, [roots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.location.hash) {
|
||||||
|
window.location.hash = "resources";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
||||||
setPendingSampleRequests((prev) => {
|
setPendingSampleRequests((prev) => {
|
||||||
@@ -108,49 +275,29 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
const clearError = (tabKey: keyof typeof errors) => {
|
||||||
null,
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
);
|
|
||||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
|
||||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
|
||||||
const [nextResourceCursor, setNextResourceCursor] = useState<
|
|
||||||
string | undefined
|
|
||||||
>();
|
|
||||||
const [nextPromptCursor, setNextPromptCursor] = useState<
|
|
||||||
string | undefined
|
|
||||||
>();
|
|
||||||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
|
||||||
const progressTokenRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("lastCommand", command);
|
|
||||||
}, [command]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("lastArgs", args);
|
|
||||||
}, [args]);
|
|
||||||
|
|
||||||
const pushHistory = (request: object, response: object) => {
|
|
||||||
setRequestHistory((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ request: JSON.stringify(request), response: JSON.stringify(response) },
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeRequest = async <T extends AnyZodObject>(
|
const makeRequest = async <T extends z.ZodType>(
|
||||||
request: ClientRequest,
|
request: ClientRequest,
|
||||||
schema: T,
|
schema: T,
|
||||||
|
tabKey?: keyof typeof errors,
|
||||||
) => {
|
) => {
|
||||||
if (!mcpClient) {
|
|
||||||
throw new Error("MCP client not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await mcpClient.request(request, schema);
|
const response = await makeConnectionRequest(request, schema);
|
||||||
pushHistory(request, response);
|
if (tabKey !== undefined) {
|
||||||
|
clearError(tabKey);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (e: unknown) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
const errorString = (e as Error).message ?? String(e);
|
||||||
|
if (tabKey !== undefined) {
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tabKey]: errorString,
|
||||||
|
}));
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -162,11 +309,29 @@ const App = () => {
|
|||||||
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
||||||
},
|
},
|
||||||
ListResourcesResultSchema,
|
ListResourcesResultSchema,
|
||||||
|
"resources",
|
||||||
);
|
);
|
||||||
setResources(resources.concat(response.resources ?? []));
|
setResources(resources.concat(response.resources ?? []));
|
||||||
setNextResourceCursor(response.nextCursor);
|
setNextResourceCursor(response.nextCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listResourceTemplates = async () => {
|
||||||
|
const response = await makeRequest(
|
||||||
|
{
|
||||||
|
method: "resources/templates/list" as const,
|
||||||
|
params: nextResourceTemplateCursor
|
||||||
|
? { cursor: nextResourceTemplateCursor }
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
ListResourceTemplatesResultSchema,
|
||||||
|
"resources",
|
||||||
|
);
|
||||||
|
setResourceTemplates(
|
||||||
|
resourceTemplates.concat(response.resourceTemplates ?? []),
|
||||||
|
);
|
||||||
|
setNextResourceTemplateCursor(response.nextCursor);
|
||||||
|
};
|
||||||
|
|
||||||
const readResource = async (uri: string) => {
|
const readResource = async (uri: string) => {
|
||||||
const response = await makeRequest(
|
const response = await makeRequest(
|
||||||
{
|
{
|
||||||
@@ -174,10 +339,43 @@ const App = () => {
|
|||||||
params: { uri },
|
params: { uri },
|
||||||
},
|
},
|
||||||
ReadResourceResultSchema,
|
ReadResourceResultSchema,
|
||||||
|
"resources",
|
||||||
);
|
);
|
||||||
setResourceContent(JSON.stringify(response, null, 2));
|
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 listPrompts = async () => {
|
||||||
const response = await makeRequest(
|
const response = await makeRequest(
|
||||||
{
|
{
|
||||||
@@ -185,6 +383,7 @@ const App = () => {
|
|||||||
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
||||||
},
|
},
|
||||||
ListPromptsResultSchema,
|
ListPromptsResultSchema,
|
||||||
|
"prompts",
|
||||||
);
|
);
|
||||||
setPrompts(response.prompts);
|
setPrompts(response.prompts);
|
||||||
setNextPromptCursor(response.nextCursor);
|
setNextPromptCursor(response.nextCursor);
|
||||||
@@ -197,6 +396,7 @@ const App = () => {
|
|||||||
params: { name, arguments: args },
|
params: { name, arguments: args },
|
||||||
},
|
},
|
||||||
GetPromptResultSchema,
|
GetPromptResultSchema,
|
||||||
|
"prompts",
|
||||||
);
|
);
|
||||||
setPromptContent(JSON.stringify(response, null, 2));
|
setPromptContent(JSON.stringify(response, null, 2));
|
||||||
};
|
};
|
||||||
@@ -208,6 +408,7 @@ const App = () => {
|
|||||||
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
||||||
},
|
},
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
|
"tools",
|
||||||
);
|
);
|
||||||
setTools(response.tools);
|
setTools(response.tools);
|
||||||
setNextToolCursor(response.nextCursor);
|
setNextToolCursor(response.nextCursor);
|
||||||
@@ -225,213 +426,284 @@ const App = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CallToolResultSchema,
|
CompatibilityCallToolResultSchema,
|
||||||
|
"tools",
|
||||||
);
|
);
|
||||||
setToolResult(JSON.stringify(response.toolResult, null, 2));
|
setToolResult(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectMcpServer = async () => {
|
const handleRootsChange = async () => {
|
||||||
try {
|
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||||
const client = new Client({
|
|
||||||
name: "mcp-inspector",
|
|
||||||
version: "0.0.1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const backendUrl = new URL("http://localhost:3000/sse");
|
|
||||||
|
|
||||||
backendUrl.searchParams.append("transportType", transportType);
|
|
||||||
if (transportType === "stdio") {
|
|
||||||
backendUrl.searchParams.append("command", command);
|
|
||||||
backendUrl.searchParams.append("args", args);
|
|
||||||
} else {
|
|
||||||
backendUrl.searchParams.append("url", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientTransport = new SSEClientTransport(backendUrl);
|
|
||||||
await client.connect(clientTransport);
|
|
||||||
|
|
||||||
client.setNotificationHandler(
|
|
||||||
ProgressNotificationSchema,
|
|
||||||
(notification) => {
|
|
||||||
setNotifications((prevNotifications) => [
|
|
||||||
...prevNotifications,
|
|
||||||
notification,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
|
||||||
return new Promise<CreateMessageResult>((resolve, reject) => {
|
|
||||||
setPendingSampleRequests((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: nextRequestId.current++, request, resolve, reject },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setMcpClient(client);
|
|
||||||
setConnectionStatus("connected");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setConnectionStatus("error");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||||
|
await makeRequest(
|
||||||
|
{
|
||||||
|
method: "logging/setLevel" as const,
|
||||||
|
params: { level },
|
||||||
|
},
|
||||||
|
z.object({}),
|
||||||
|
);
|
||||||
|
setLogLevel(level);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.location.pathname === "/oauth/callback") {
|
||||||
|
const OAuthCallback = React.lazy(
|
||||||
|
() => import("./components/OAuthCallback"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<OAuthCallback />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar connectionStatus={connectionStatus} />
|
<Sidebar
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
transportType={transportType}
|
||||||
|
setTransportType={setTransportType}
|
||||||
|
command={command}
|
||||||
|
setCommand={setCommand}
|
||||||
|
args={args}
|
||||||
|
setArgs={setArgs}
|
||||||
|
sseUrl={sseUrl}
|
||||||
|
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 flex flex-col overflow-hidden">
|
||||||
<h1 className="text-2xl font-bold p-4">MCP Inspector</h1>
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="flex-1 overflow-auto flex">
|
{mcpClient ? (
|
||||||
<div className="flex-1">
|
<Tabs
|
||||||
<div className="p-4 bg-white shadow-md m-4 rounded-md">
|
defaultValue={
|
||||||
<h2 className="text-lg font-semibold mb-2">Connect MCP Server</h2>
|
Object.keys(serverCapabilities ?? {}).includes(
|
||||||
<div className="flex space-x-2 mb-2">
|
window.location.hash.slice(1),
|
||||||
<Select
|
)
|
||||||
value={transportType}
|
? window.location.hash.slice(1)
|
||||||
onValueChange={(value: "stdio" | "sse") =>
|
: serverCapabilities?.resources
|
||||||
setTransportType(value)
|
? "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"
|
||||||
|
disabled={!serverCapabilities?.resources}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[180px]">
|
<Files className="w-4 h-4 mr-2" />
|
||||||
<SelectValue placeholder="Select transport type" />
|
Resources
|
||||||
</SelectTrigger>
|
</TabsTrigger>
|
||||||
<SelectContent>
|
<TabsTrigger
|
||||||
<SelectItem value="stdio">STDIO</SelectItem>
|
value="prompts"
|
||||||
<SelectItem value="sse">SSE</SelectItem>
|
disabled={!serverCapabilities?.prompts}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
{transportType === "stdio" ? (
|
Prompts
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tools"
|
||||||
|
disabled={!serverCapabilities?.tools}
|
||||||
|
>
|
||||||
|
<Hammer className="w-4 h-4 mr-2" />
|
||||||
|
Tools
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ping">
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Ping
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sampling" className="relative">
|
||||||
|
<Hash className="w-4 h-4 mr-2" />
|
||||||
|
Sampling
|
||||||
|
{pendingSampleRequests.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
|
||||||
|
{pendingSampleRequests.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="roots">
|
||||||
|
<FolderTree className="w-4 h-4 mr-2" />
|
||||||
|
Roots
|
||||||
|
</TabsTrigger>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<ResourcesTab
|
||||||
placeholder="Command"
|
resources={resources}
|
||||||
value={command}
|
resourceTemplates={resourceTemplates}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
listResources={() => {
|
||||||
|
clearError("resources");
|
||||||
|
listResources();
|
||||||
|
}}
|
||||||
|
clearResources={() => {
|
||||||
|
setResources([]);
|
||||||
|
setNextResourceCursor(undefined);
|
||||||
|
}}
|
||||||
|
listResourceTemplates={() => {
|
||||||
|
clearError("resources");
|
||||||
|
listResourceTemplates();
|
||||||
|
}}
|
||||||
|
clearResourceTemplates={() => {
|
||||||
|
setResourceTemplates([]);
|
||||||
|
setNextResourceTemplateCursor(undefined);
|
||||||
|
}}
|
||||||
|
readResource={(uri) => {
|
||||||
|
clearError("resources");
|
||||||
|
readResource(uri);
|
||||||
|
}}
|
||||||
|
selectedResource={selectedResource}
|
||||||
|
setSelectedResource={(resource) => {
|
||||||
|
clearError("resources");
|
||||||
|
setSelectedResource(resource);
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
error={errors.resources}
|
||||||
/>
|
/>
|
||||||
<Input
|
<PromptsTab
|
||||||
placeholder="Arguments (space-separated)"
|
prompts={prompts}
|
||||||
value={args}
|
listPrompts={() => {
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
clearError("prompts");
|
||||||
|
listPrompts();
|
||||||
|
}}
|
||||||
|
clearPrompts={() => {
|
||||||
|
setPrompts([]);
|
||||||
|
setNextPromptCursor(undefined);
|
||||||
|
}}
|
||||||
|
getPrompt={(name, args) => {
|
||||||
|
clearError("prompts");
|
||||||
|
getPrompt(name, args);
|
||||||
|
}}
|
||||||
|
selectedPrompt={selectedPrompt}
|
||||||
|
setSelectedPrompt={(prompt) => {
|
||||||
|
clearError("prompts");
|
||||||
|
setSelectedPrompt(prompt);
|
||||||
|
}}
|
||||||
|
handleCompletion={handleCompletion}
|
||||||
|
completionsSupported={completionsSupported}
|
||||||
|
promptContent={promptContent}
|
||||||
|
nextCursor={nextPromptCursor}
|
||||||
|
error={errors.prompts}
|
||||||
|
/>
|
||||||
|
<ToolsTab
|
||||||
|
tools={tools}
|
||||||
|
listTools={() => {
|
||||||
|
clearError("tools");
|
||||||
|
listTools();
|
||||||
|
}}
|
||||||
|
clearTools={() => {
|
||||||
|
setTools([]);
|
||||||
|
setNextToolCursor(undefined);
|
||||||
|
}}
|
||||||
|
callTool={(name, params) => {
|
||||||
|
clearError("tools");
|
||||||
|
callTool(name, params);
|
||||||
|
}}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
setSelectedTool={(tool) => {
|
||||||
|
clearError("tools");
|
||||||
|
setSelectedTool(tool);
|
||||||
|
setToolResult(null);
|
||||||
|
}}
|
||||||
|
toolResult={toolResult}
|
||||||
|
nextCursor={nextToolCursor}
|
||||||
|
error={errors.tools}
|
||||||
|
/>
|
||||||
|
<ConsoleTab />
|
||||||
|
<PingTab
|
||||||
|
onPingClick={() => {
|
||||||
|
void makeRequest(
|
||||||
|
{
|
||||||
|
method: "ping" as const,
|
||||||
|
},
|
||||||
|
EmptyResultSchema,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SamplingTab
|
||||||
|
pendingRequests={pendingSampleRequests}
|
||||||
|
onApprove={handleApproveSampling}
|
||||||
|
onReject={handleRejectSampling}
|
||||||
|
/>
|
||||||
|
<RootsTab
|
||||||
|
roots={roots}
|
||||||
|
setRoots={setRoots}
|
||||||
|
onRootsChange={handleRootsChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
placeholder="URL"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Button onClick={connectMcpServer}>
|
|
||||||
<Play className="w-4 h-4 mr-2" />
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-lg text-gray-500">
|
||||||
|
Connect to an MCP server to start inspecting
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{mcpClient ? (
|
)}
|
||||||
<Tabs defaultValue="resources" className="w-full p-4">
|
</div>
|
||||||
<TabsList className="mb-4 p-0">
|
<div
|
||||||
<TabsTrigger value="resources">
|
className="relative border-t border-border"
|
||||||
<Files className="w-4 h-4 mr-2" />
|
style={{
|
||||||
Resources
|
height: `${historyPaneHeight}px`,
|
||||||
</TabsTrigger>
|
}}
|
||||||
<TabsTrigger value="prompts">
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<div
|
||||||
Prompts
|
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50"
|
||||||
</TabsTrigger>
|
onMouseDown={handleDragStart}
|
||||||
<TabsTrigger value="requests" disabled>
|
>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<div className="w-8 h-1 rounded-full bg-border" />
|
||||||
Requests
|
</div>
|
||||||
</TabsTrigger>
|
<div className="h-full overflow-auto">
|
||||||
<TabsTrigger value="tools">
|
<HistoryAndNotifications
|
||||||
<Hammer className="w-4 h-4 mr-2" />
|
requestHistory={requestHistory}
|
||||||
Tools
|
serverNotifications={notifications}
|
||||||
</TabsTrigger>
|
/>
|
||||||
<TabsTrigger value="console" disabled>
|
|
||||||
<Terminal className="w-4 h-4 mr-2" />
|
|
||||||
Console
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ping">
|
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
|
||||||
Ping
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="sampling" className="relative">
|
|
||||||
<Hash className="w-4 h-4 mr-2" />
|
|
||||||
Sampling
|
|
||||||
{pendingSampleRequests.length > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
|
|
||||||
{pendingSampleRequests.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
<ResourcesTab
|
|
||||||
resources={resources}
|
|
||||||
listResources={listResources}
|
|
||||||
readResource={readResource}
|
|
||||||
selectedResource={selectedResource}
|
|
||||||
setSelectedResource={setSelectedResource}
|
|
||||||
resourceContent={resourceContent}
|
|
||||||
nextCursor={nextResourceCursor}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
<PromptsTab
|
|
||||||
prompts={prompts}
|
|
||||||
listPrompts={listPrompts}
|
|
||||||
getPrompt={getPrompt}
|
|
||||||
selectedPrompt={selectedPrompt}
|
|
||||||
setSelectedPrompt={setSelectedPrompt}
|
|
||||||
promptContent={promptContent}
|
|
||||||
nextCursor={nextPromptCursor}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
<RequestsTab />
|
|
||||||
<ToolsTab
|
|
||||||
tools={tools}
|
|
||||||
listTools={listTools}
|
|
||||||
callTool={callTool}
|
|
||||||
selectedTool={selectedTool}
|
|
||||||
setSelectedTool={(tool) => {
|
|
||||||
setSelectedTool(tool);
|
|
||||||
setToolResult("");
|
|
||||||
}}
|
|
||||||
toolResult={toolResult}
|
|
||||||
nextCursor={nextToolCursor}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
<ConsoleTab />
|
|
||||||
<PingTab
|
|
||||||
onPingClick={() => {
|
|
||||||
void makeRequest(
|
|
||||||
{
|
|
||||||
method: "ping" as const,
|
|
||||||
},
|
|
||||||
EmptyResultSchema,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SamplingTab
|
|
||||||
pendingRequests={pendingSampleRequests}
|
|
||||||
onApprove={handleApproveSampling}
|
|
||||||
onReject={handleRejectSampling}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<p className="text-lg text-gray-500">
|
|
||||||
Connect to an MCP server to start inspecting
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HistoryAndNotifications
|
|
||||||
requestHistory={requestHistory}
|
|
||||||
serverNotifications={notifications}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
1
client/src/__mocks__/styleMock.js
Normal file
1
client/src/__mocks__/styleMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
421
client/src/components/DynamicJsonForm.tsx
Normal file
421
client/src/components/DynamicJsonForm.tsx
Normal 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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Copy } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const HistoryAndNotifications = ({
|
const HistoryAndNotifications = ({
|
||||||
requestHistory,
|
requestHistory,
|
||||||
serverNotifications,
|
serverNotifications,
|
||||||
}: {
|
}: {
|
||||||
requestHistory: Array<{ request: string; response: string | null }>;
|
requestHistory: Array<{ request: string; response?: string }>;
|
||||||
serverNotifications: ServerNotification[];
|
serverNotifications: ServerNotification[];
|
||||||
}) => {
|
}) => {
|
||||||
const [expandedRequests, setExpandedRequests] = useState<{
|
const [expandedRequests, setExpandedRequests] = useState<{
|
||||||
@@ -24,13 +24,9 @@ const HistoryAndNotifications = ({
|
|||||||
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
|
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-white shadow-md p-4 overflow-hidden flex flex-col h-full">
|
<div className="bg-card overflow-hidden flex h-full">
|
||||||
<div className="flex-1 overflow-y-auto mb-4 border-b pb-4">
|
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
<h2 className="text-lg font-semibold mb-4">History</h2>
|
||||||
{requestHistory.length === 0 ? (
|
{requestHistory.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic">No history yet</p>
|
<p className="text-sm text-gray-500 italic">No history yet</p>
|
||||||
@@ -42,7 +38,7 @@ const HistoryAndNotifications = ({
|
|||||||
.map((request, index) => (
|
.map((request, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className="text-sm text-gray-600 bg-gray-100 p-2 rounded"
|
className="text-sm text-foreground bg-secondary p-2 rounded"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center cursor-pointer"
|
className="flex justify-between items-center cursor-pointer"
|
||||||
@@ -67,16 +63,12 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-blue-600">
|
<span className="font-semibold text-blue-600">
|
||||||
Request:
|
Request:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(request.request)}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-blue-50 p-2 rounded">
|
|
||||||
{JSON.stringify(JSON.parse(request.request), null, 2)}
|
<JsonView
|
||||||
</pre>
|
data={request.request}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{request.response && (
|
{request.response && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -84,20 +76,11 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-green-600">
|
<span className="font-semibold text-green-600">
|
||||||
Response:
|
Response:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(request.response!)}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-green-50 p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(
|
data={request.response}
|
||||||
JSON.parse(request.response),
|
className="bg-background"
|
||||||
null,
|
/>
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -107,7 +90,7 @@ const HistoryAndNotifications = ({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
||||||
{serverNotifications.length === 0 ? (
|
{serverNotifications.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic">No notifications yet</p>
|
<p className="text-sm text-gray-500 italic">No notifications yet</p>
|
||||||
@@ -119,7 +102,7 @@ const HistoryAndNotifications = ({
|
|||||||
.map((notification, index) => (
|
.map((notification, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className="text-sm text-gray-600 bg-gray-100 p-2 rounded"
|
className="text-sm text-foreground bg-secondary p-2 rounded"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center cursor-pointer"
|
className="flex justify-between items-center cursor-pointer"
|
||||||
@@ -137,18 +120,11 @@ const HistoryAndNotifications = ({
|
|||||||
<span className="font-semibold text-purple-600">
|
<span className="font-semibold text-purple-600">
|
||||||
Details:
|
Details:
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
copyToClipboard(JSON.stringify(notification))
|
|
||||||
}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap break-words bg-purple-50 p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(notification, null, 2)}
|
data={JSON.stringify(notification, null, 2)}
|
||||||
</pre>
|
className="bg-background"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
67
client/src/components/JsonEditor.tsx
Normal file
67
client/src/components/JsonEditor.tsx
Normal 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;
|
||||||
290
client/src/components/JsonView.tsx
Normal file
290
client/src/components/JsonView.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { JsonValue } from "./DynamicJsonForm";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Copy, CheckCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface JsonViewProps {
|
||||||
|
data: unknown;
|
||||||
|
name?: string;
|
||||||
|
initialExpandDepth?: number;
|
||||||
|
className?: string;
|
||||||
|
withCopyButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (
|
||||||
|
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||||
|
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||||
|
) {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { success: true, data: JSON.parse(str) };
|
||||||
|
} catch {
|
||||||
|
return { success: false, data: str };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonView = memo(
|
||||||
|
({
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
initialExpandDepth = 3,
|
||||||
|
className,
|
||||||
|
withCopyButton = true,
|
||||||
|
}: JsonViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
if (copied) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const normalizedData = useMemo(() => {
|
||||||
|
return typeof data === "string"
|
||||||
|
? tryParseJson(data).success
|
||||||
|
? tryParseJson(data).data
|
||||||
|
: data
|
||||||
|
: data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
typeof normalizedData === "string"
|
||||||
|
? normalizedData
|
||||||
|
: JSON.stringify(normalizedData, null, 2),
|
||||||
|
);
|
||||||
|
setCopied(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [toast, normalizedData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("p-4 border rounded relative", className)}>
|
||||||
|
{withCopyButton && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="font-mono text-sm transition-all duration-300">
|
||||||
|
<JsonNode
|
||||||
|
data={normalizedData as JsonValue}
|
||||||
|
name={name}
|
||||||
|
depth={0}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonView.displayName = "JsonView";
|
||||||
|
|
||||||
|
interface JsonNodeProps {
|
||||||
|
data: JsonValue;
|
||||||
|
name?: string;
|
||||||
|
depth: number;
|
||||||
|
initialExpandDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonNode = memo(
|
||||||
|
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||||
|
|
||||||
|
const getDataType = (value: JsonValue): string => {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === null) return "null";
|
||||||
|
return typeof value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataType = getDataType(data);
|
||||||
|
|
||||||
|
const typeStyleMap: Record<string, string> = {
|
||||||
|
number: "text-blue-600",
|
||||||
|
boolean: "text-amber-600",
|
||||||
|
null: "text-purple-600",
|
||||||
|
undefined: "text-gray-600",
|
||||||
|
string: "text-green-600 break-all whitespace-pre-wrap",
|
||||||
|
default: "text-gray-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCollapsible = (isArray: boolean) => {
|
||||||
|
const items = isArray
|
||||||
|
? (data as JsonValue[])
|
||||||
|
: Object.entries(data as Record<string, JsonValue>);
|
||||||
|
const itemCount = items.length;
|
||||||
|
const isEmpty = itemCount === 0;
|
||||||
|
|
||||||
|
const symbolMap = {
|
||||||
|
open: isArray ? "[" : "{",
|
||||||
|
close: isArray ? "]" : "}",
|
||||||
|
collapsed: isArray ? "[ ... ]" : "{ ... }",
|
||||||
|
empty: isArray ? "[]" : "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">{symbolMap.empty}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpanded ? (
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{symbolMap.open}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{symbolMap.collapsed}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
|
||||||
|
{isArray
|
||||||
|
? (items as JsonValue[]).map((item, index) => (
|
||||||
|
<div key={index} className="my-1">
|
||||||
|
<JsonNode
|
||||||
|
data={item}
|
||||||
|
name={`${index}`}
|
||||||
|
depth={depth + 1}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: (items as [string, JsonValue][]).map(([key, value]) => (
|
||||||
|
<div key={key} className="my-1">
|
||||||
|
<JsonNode
|
||||||
|
data={value}
|
||||||
|
name={key}
|
||||||
|
depth={depth + 1}
|
||||||
|
initialExpandDepth={initialExpandDepth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{symbolMap.close}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderString = (value: string) => {
|
||||||
|
const maxLength = 100;
|
||||||
|
const isTooLong = value.length > maxLength;
|
||||||
|
|
||||||
|
if (!isTooLong) {
|
||||||
|
return (
|
||||||
|
<div className="flex mr-1 rounded hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre className={typeStyleMap.string}>"{value}"</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre
|
||||||
|
className={clsx(
|
||||||
|
typeStyleMap.string,
|
||||||
|
"cursor-pointer group-hover:text-green-500",
|
||||||
|
)}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||||
|
>
|
||||||
|
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (dataType) {
|
||||||
|
case "object":
|
||||||
|
case "array":
|
||||||
|
return renderCollapsible(dataType === "array");
|
||||||
|
case "string":
|
||||||
|
return renderString(data as string);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
|
||||||
|
{name && (
|
||||||
|
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||||
|
{name}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
||||||
|
{data === null ? "null" : String(data)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode.displayName = "JsonNode";
|
||||||
|
|
||||||
|
export default JsonView;
|
||||||
@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
|
|||||||
type ListPaneProps<T> = {
|
type ListPaneProps<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
listItems: () => void;
|
listItems: () => void;
|
||||||
|
clearItems: () => void;
|
||||||
setSelectedItem: (item: T) => void;
|
setSelectedItem: (item: T) => void;
|
||||||
renderItem: (item: T) => React.ReactNode;
|
renderItem: (item: T) => React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,15 +14,16 @@ type ListPaneProps<T> = {
|
|||||||
const ListPane = <T extends object>({
|
const ListPane = <T extends object>({
|
||||||
items,
|
items,
|
||||||
listItems,
|
listItems,
|
||||||
|
clearItems,
|
||||||
setSelectedItem,
|
setSelectedItem,
|
||||||
renderItem,
|
renderItem,
|
||||||
title,
|
title,
|
||||||
buttonText,
|
buttonText,
|
||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -32,11 +34,19 @@ const ListPane = <T extends object>({
|
|||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mb-4"
|
||||||
|
onClick={clearItems}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center p-2 rounded hover:bg-gray-50 cursor-pointer"
|
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
>
|
>
|
||||||
{renderItem(item)}
|
{renderItem(item)}
|
||||||
|
|||||||
56
client/src/components/OAuthCallback.tsx
Normal file
56
client/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { authProvider } from "../lib/auth";
|
||||||
|
import { SESSION_KEYS } from "../lib/constants";
|
||||||
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
|
||||||
|
const OAuthCallback = () => {
|
||||||
|
const hasProcessedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
// Skip if we've already processed this callback
|
||||||
|
if (hasProcessedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasProcessedRef.current = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||||
|
|
||||||
|
if (!code || !serverUrl) {
|
||||||
|
console.error("Missing code or server URL");
|
||||||
|
window.location.href = "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await auth(authProvider, {
|
||||||
|
serverUrl,
|
||||||
|
authorizationCode: code,
|
||||||
|
});
|
||||||
|
if (result !== "AUTHORIZED") {
|
||||||
|
throw new Error(
|
||||||
|
`Expected to be authorized after providing auth code, got: ${result}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to the main app with server URL to trigger auto-connect
|
||||||
|
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OAuth callback error:", error);
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void handleCallback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OAuthCallback;
|
||||||
@@ -7,11 +7,9 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
|||||||
<div className="col-span-2 flex justify-center items-center">
|
<div className="col-span-2 flex justify-center items-center">
|
||||||
<Button
|
<Button
|
||||||
onClick={onPingClick}
|
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>
|
Ping Server
|
||||||
MEGA PING
|
|
||||||
<span className="text-3xl ml-2">💥</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
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 { AlertCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
|
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
export type Prompt = {
|
export type Prompt = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,26 +28,53 @@ export type Prompt = {
|
|||||||
const PromptsTab = ({
|
const PromptsTab = ({
|
||||||
prompts,
|
prompts,
|
||||||
listPrompts,
|
listPrompts,
|
||||||
|
clearPrompts,
|
||||||
getPrompt,
|
getPrompt,
|
||||||
selectedPrompt,
|
selectedPrompt,
|
||||||
setSelectedPrompt,
|
setSelectedPrompt,
|
||||||
|
handleCompletion,
|
||||||
|
completionsSupported,
|
||||||
promptContent,
|
promptContent,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
prompts: Prompt[];
|
prompts: Prompt[];
|
||||||
listPrompts: () => void;
|
listPrompts: () => void;
|
||||||
|
clearPrompts: () => void;
|
||||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
selectedPrompt: Prompt | null;
|
selectedPrompt: Prompt | null;
|
||||||
setSelectedPrompt: (prompt: Prompt) => void;
|
setSelectedPrompt: (prompt: Prompt) => void;
|
||||||
|
handleCompletion: (
|
||||||
|
ref: PromptReference | ResourceReference,
|
||||||
|
argName: string,
|
||||||
|
value: string,
|
||||||
|
) => Promise<string[]>;
|
||||||
|
completionsSupported: boolean;
|
||||||
promptContent: string;
|
promptContent: string;
|
||||||
nextCursor: ListPromptsResult["nextCursor"];
|
nextCursor: ListPromptsResult["nextCursor"];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
|
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 }));
|
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
|
||||||
|
|
||||||
|
if (selectedPrompt) {
|
||||||
|
requestCompletions(
|
||||||
|
{
|
||||||
|
type: "ref/prompt",
|
||||||
|
name: selectedPrompt.name,
|
||||||
|
},
|
||||||
|
argName,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGetPrompt = () => {
|
const handleGetPrompt = () => {
|
||||||
@@ -55,6 +88,7 @@ const PromptsTab = ({
|
|||||||
<ListPane
|
<ListPane
|
||||||
items={prompts}
|
items={prompts}
|
||||||
listItems={listPrompts}
|
listItems={listPrompts}
|
||||||
|
clearItems={clearPrompts}
|
||||||
setSelectedItem={(prompt) => {
|
setSelectedItem={(prompt) => {
|
||||||
setSelectedPrompt(prompt);
|
setSelectedPrompt(prompt);
|
||||||
setPromptArgs({});
|
setPromptArgs({});
|
||||||
@@ -70,7 +104,7 @@ const PromptsTab = ({
|
|||||||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||||
@@ -93,14 +127,17 @@ const PromptsTab = ({
|
|||||||
{selectedPrompt.arguments?.map((arg) => (
|
{selectedPrompt.arguments?.map((arg) => (
|
||||||
<div key={arg.name}>
|
<div key={arg.name}>
|
||||||
<Label htmlFor={arg.name}>{arg.name}</Label>
|
<Label htmlFor={arg.name}>{arg.name}</Label>
|
||||||
<Input
|
<Combobox
|
||||||
id={arg.name}
|
id={arg.name}
|
||||||
placeholder={`Enter ${arg.name}`}
|
placeholder={`Enter ${arg.name}`}
|
||||||
value={promptArgs[arg.name] || ""}
|
value={promptArgs[arg.name] || ""}
|
||||||
onChange={(e) =>
|
onChange={(value) => handleInputChange(arg.name, value)}
|
||||||
handleInputChange(arg.name, e.target.value)
|
onInputChange={(value) =>
|
||||||
|
handleInputChange(arg.name, value)
|
||||||
}
|
}
|
||||||
|
options={completions[arg.name] || []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{arg.description && (
|
{arg.description && (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{arg.description}
|
{arg.description}
|
||||||
@@ -115,11 +152,7 @@ const PromptsTab = ({
|
|||||||
Get Prompt
|
Get Prompt
|
||||||
</Button>
|
</Button>
|
||||||
{promptContent && (
|
{promptContent && (
|
||||||
<Textarea
|
<JsonView data={promptContent} withCopyButton={false} />
|
||||||
value={promptContent}
|
|
||||||
readOnly
|
|
||||||
className="h-64 font-mono"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { TabsContent } from "@/components/ui/tabs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Send } from "lucide-react";
|
|
||||||
|
|
||||||
const RequestsTab = () => (
|
|
||||||
<TabsContent value="requests" className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Input placeholder="Method name" />
|
|
||||||
<Button>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Request parameters (JSON)"
|
|
||||||
className="h-64 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg h-96 font-mono text-sm overflow-auto">
|
|
||||||
<div className="text-gray-500 mb-2">Response:</div>
|
|
||||||
{/* Response content would go here */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default RequestsTab;
|
|
||||||
@@ -1,88 +1,269 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { ListResourcesResult, Resource } from "@modelcontextprotocol/sdk/types.js";
|
import {
|
||||||
|
ListResourcesResult,
|
||||||
|
Resource,
|
||||||
|
ResourceTemplate,
|
||||||
|
ListResourceTemplatesResult,
|
||||||
|
ResourceReference,
|
||||||
|
PromptReference,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
|
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const ResourcesTab = ({
|
const ResourcesTab = ({
|
||||||
resources,
|
resources,
|
||||||
|
resourceTemplates,
|
||||||
listResources,
|
listResources,
|
||||||
|
clearResources,
|
||||||
|
listResourceTemplates,
|
||||||
|
clearResourceTemplates,
|
||||||
readResource,
|
readResource,
|
||||||
selectedResource,
|
selectedResource,
|
||||||
setSelectedResource,
|
setSelectedResource,
|
||||||
|
resourceSubscriptionsSupported,
|
||||||
|
resourceSubscriptions,
|
||||||
|
subscribeToResource,
|
||||||
|
unsubscribeFromResource,
|
||||||
|
handleCompletion,
|
||||||
|
completionsSupported,
|
||||||
resourceContent,
|
resourceContent,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
|
nextTemplateCursor,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
|
resourceTemplates: ResourceTemplate[];
|
||||||
listResources: () => void;
|
listResources: () => void;
|
||||||
|
clearResources: () => void;
|
||||||
|
listResourceTemplates: () => void;
|
||||||
|
clearResourceTemplates: () => void;
|
||||||
readResource: (uri: string) => void;
|
readResource: (uri: string) => void;
|
||||||
selectedResource: Resource | null;
|
selectedResource: Resource | null;
|
||||||
setSelectedResource: (resource: Resource) => void;
|
setSelectedResource: (resource: Resource | null) => void;
|
||||||
|
handleCompletion: (
|
||||||
|
ref: ResourceReference | PromptReference,
|
||||||
|
argName: string,
|
||||||
|
value: string,
|
||||||
|
) => Promise<string[]>;
|
||||||
|
completionsSupported: boolean;
|
||||||
resourceContent: string;
|
resourceContent: string;
|
||||||
nextCursor: ListResourcesResult["nextCursor"];
|
nextCursor: ListResourcesResult["nextCursor"];
|
||||||
|
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) => (
|
resourceSubscriptionsSupported: boolean;
|
||||||
<TabsContent value="resources" className="grid grid-cols-2 gap-4">
|
resourceSubscriptions: Set<string>;
|
||||||
<ListPane
|
subscribeToResource: (uri: string) => void;
|
||||||
items={resources}
|
unsubscribeFromResource: (uri: string) => void;
|
||||||
listItems={listResources}
|
}) => {
|
||||||
setSelectedItem={(resource) => {
|
const [selectedTemplate, setSelectedTemplate] =
|
||||||
setSelectedResource(resource);
|
useState<ResourceTemplate | null>(null);
|
||||||
readResource(resource.uri);
|
const [templateValues, setTemplateValues] = useState<Record<string, string>>(
|
||||||
}}
|
{},
|
||||||
renderItem={(resource) => (
|
);
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
|
||||||
<span className="flex-1 truncate" title={resource.uri.toString()}>
|
|
||||||
{resource.name}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
title="Resources"
|
|
||||||
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
|
||||||
isButtonDisabled={!nextCursor && resources.length > 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
const { completions, clearCompletions, requestCompletions } =
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
useCompletionState(handleCompletion, completionsSupported);
|
||||||
<h3 className="font-semibold truncate" title={selectedResource?.name}>
|
|
||||||
{selectedResource ? selectedResource.name : "Select a resource"}
|
useEffect(() => {
|
||||||
</h3>
|
clearCompletions();
|
||||||
{selectedResource && (
|
}, [clearCompletions]);
|
||||||
<Button
|
|
||||||
variant="outline"
|
const fillTemplate = (
|
||||||
size="sm"
|
template: string,
|
||||||
onClick={() => readResource(selectedResource.uri)}
|
values: Record<string, string>,
|
||||||
|
): string => {
|
||||||
|
return template.replace(
|
||||||
|
/{([^}]+)}/g,
|
||||||
|
(_, key) => values[key] || `{${key}}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
readResource(uri);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
// We don't have the full Resource object here, so we create a partial one
|
||||||
|
setSelectedResource({ uri, name: uri } as Resource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
|
||||||
|
<ListPane
|
||||||
|
items={resources}
|
||||||
|
listItems={listResources}
|
||||||
|
clearItems={clearResources}
|
||||||
|
setSelectedItem={(resource) => {
|
||||||
|
setSelectedResource(resource);
|
||||||
|
readResource(resource.uri);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}}
|
||||||
|
renderItem={(resource) => (
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate" title={resource.uri.toString()}>
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
title="Resources"
|
||||||
|
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
||||||
|
isButtonDisabled={!nextCursor && resources.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListPane
|
||||||
|
items={resourceTemplates}
|
||||||
|
listItems={listResourceTemplates}
|
||||||
|
clearItems={clearResourceTemplates}
|
||||||
|
setSelectedItem={(template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setSelectedResource(null);
|
||||||
|
setTemplateValues({});
|
||||||
|
}}
|
||||||
|
renderItem={(template) => (
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate" title={template.uriTemplate}>
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
title="Resource Templates"
|
||||||
|
buttonText={
|
||||||
|
nextTemplateCursor ? "List More Templates" : "List Templates"
|
||||||
|
}
|
||||||
|
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg shadow">
|
||||||
|
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h3
|
||||||
|
className="font-semibold truncate"
|
||||||
|
title={selectedResource?.name || selectedTemplate?.name}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
{selectedResource
|
||||||
Refresh
|
? selectedResource.name
|
||||||
</Button>
|
: selectedTemplate
|
||||||
)}
|
? selectedTemplate.name
|
||||||
|
: "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"
|
||||||
|
onClick={() => readResource(selectedResource.uri)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : selectedResource ? (
|
||||||
|
<JsonView
|
||||||
|
data={resourceContent}
|
||||||
|
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
) : selectedTemplate ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedTemplate.description}
|
||||||
|
</p>
|
||||||
|
{selectedTemplate.uriTemplate
|
||||||
|
.match(/{([^}]+)}/g)
|
||||||
|
?.map((param) => {
|
||||||
|
const key = param.slice(1, -1);
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<Label htmlFor={key}>{key}</Label>
|
||||||
|
<Combobox
|
||||||
|
id={key}
|
||||||
|
placeholder={`Enter ${key}`}
|
||||||
|
value={templateValues[key] || ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleTemplateValueChange(key, value)
|
||||||
|
}
|
||||||
|
onInputChange={(value) =>
|
||||||
|
handleTemplateValueChange(key, value)
|
||||||
|
}
|
||||||
|
options={completions[key] || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
onClick={handleReadTemplateResource}
|
||||||
|
disabled={Object.keys(templateValues).length === 0}
|
||||||
|
>
|
||||||
|
Read Resource
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Select a resource or template from the list to view its contents
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
</TabsContent>
|
||||||
{error ? (
|
);
|
||||||
<Alert variant="destructive">
|
};
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : selectedResource ? (
|
|
||||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words">
|
|
||||||
{resourceContent}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Select a resource from the list to view its contents
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ResourcesTab;
|
export default ResourcesTab;
|
||||||
|
|||||||
77
client/src/components/RootsTab.tsx
Normal file
77
client/src/components/RootsTab.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { Plus, Minus, Save } from "lucide-react";
|
||||||
|
|
||||||
|
const RootsTab = ({
|
||||||
|
roots,
|
||||||
|
setRoots,
|
||||||
|
onRootsChange,
|
||||||
|
}: {
|
||||||
|
roots: Root[];
|
||||||
|
setRoots: React.Dispatch<React.SetStateAction<Root[]>>;
|
||||||
|
onRootsChange: () => void;
|
||||||
|
}) => {
|
||||||
|
const addRoot = () => {
|
||||||
|
setRoots((currentRoots) => [...currentRoots, { uri: "file://", name: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRoot = (index: number) => {
|
||||||
|
setRoots((currentRoots) => currentRoots.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRoot = (index: number, field: keyof Root, value: string) => {
|
||||||
|
setRoots((currentRoots) =>
|
||||||
|
currentRoots.map((root, i) =>
|
||||||
|
i === index ? { ...root, [field]: value } : root,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onRootsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="roots" className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Configure the root directories that the server can access
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{roots.map((root, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="file:// URI"
|
||||||
|
value={root.uri}
|
||||||
|
onChange={(e) => updateRoot(index, "uri", e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeRoot(index)}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={addRoot}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Root
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootsTab;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
export type PendingRequest = {
|
export type PendingRequest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,9 +44,11 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
|||||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||||
{pendingRequests.map((request) => (
|
{pendingRequests.map((request) => (
|
||||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||||
<pre className="bg-gray-50 p-2 rounded">
|
<JsonView
|
||||||
{JSON.stringify(request.request, null, 2)}
|
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||||
</pre>
|
data={JSON.stringify(request.request)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
||||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||||
|
|||||||
@@ -1,39 +1,549 @@
|
|||||||
import { Menu, Settings } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CircleHelp,
|
||||||
|
Bug,
|
||||||
|
Github,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
RefreshCwOff,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { StdErrNotification } from "@/lib/notificationTypes";
|
||||||
|
import {
|
||||||
|
LoggingLevel,
|
||||||
|
LoggingLevelSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { ConnectionStatus } from "@/lib/constants";
|
||||||
|
import useTheme from "../lib/useTheme";
|
||||||
|
import { version } from "../../../package.json";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const Sidebar = ({ connectionStatus }: { connectionStatus: string }) => (
|
interface SidebarProps {
|
||||||
<div className="w-64 bg-white border-r border-gray-200">
|
connectionStatus: ConnectionStatus;
|
||||||
<div className="flex items-center p-4 border-b border-gray-200">
|
transportType: "stdio" | "sse";
|
||||||
<Menu className="w-6 h-6 text-gray-500" />
|
setTransportType: (type: "stdio" | "sse") => void;
|
||||||
<h1 className="ml-2 text-lg font-semibold">MCP Inspector</h1>
|
command: string;
|
||||||
</div>
|
setCommand: (command: string) => void;
|
||||||
|
args: string;
|
||||||
|
setArgs: (args: string) => void;
|
||||||
|
sseUrl: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
<div className="p-4">
|
const Sidebar = ({
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
connectionStatus,
|
||||||
<div
|
transportType,
|
||||||
className={`w-2 h-2 rounded-full ${
|
setTransportType,
|
||||||
connectionStatus === "connected"
|
command,
|
||||||
? "bg-green-500"
|
setCommand,
|
||||||
: connectionStatus === "error"
|
args,
|
||||||
? "bg-red-500"
|
setArgs,
|
||||||
: "bg-gray-500"
|
sseUrl,
|
||||||
}`}
|
setSseUrl,
|
||||||
/>
|
env,
|
||||||
<span className="text-sm text-gray-600">
|
setEnv,
|
||||||
{connectionStatus === "connected"
|
bearerToken,
|
||||||
? "Connected"
|
setBearerToken,
|
||||||
: connectionStatus === "error"
|
onConnect,
|
||||||
? "Connection Error"
|
onDisconnect,
|
||||||
: "Disconnected"}
|
stdErrNotifications,
|
||||||
</span>
|
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">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="ml-2 text-lg font-semibold">
|
||||||
|
MCP Inspector v{version}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" className="w-full justify-start">
|
<div className="p-4 flex-1 overflow-auto">
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<div className="space-y-4">
|
||||||
Connection Settings
|
<div className="space-y-2">
|
||||||
</Button>
|
<label className="text-sm font-medium">Transport Type</label>
|
||||||
|
<Select
|
||||||
|
value={transportType}
|
||||||
|
onValueChange={(value: "stdio" | "sse") =>
|
||||||
|
setTransportType(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select transport type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stdio">STDIO</SelectItem>
|
||||||
|
<SelectItem value="sse">SSE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transportType === "stdio" ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Command</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Command"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Arguments</label>
|
||||||
|
<Input
|
||||||
|
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">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowEnvVars(!showEnvVars)}
|
||||||
|
className="flex items-center w-full"
|
||||||
|
data-testid="env-vars-button"
|
||||||
|
>
|
||||||
|
{showEnvVars ? (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Environment Variables
|
||||||
|
</Button>
|
||||||
|
{showEnvVars && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(env).map(([key, value], idx) => (
|
||||||
|
<div key={idx} className="space-y-2 pb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Key"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newKey = e.target.value;
|
||||||
|
const newEnv = 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) => {
|
||||||
|
const newEnv = { ...env };
|
||||||
|
newEnv[key] = e.target.value;
|
||||||
|
setEnv(newEnv);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
setShownEnvVars((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
shownEnvVars.has(key) ? "Hide value" : "Show value"
|
||||||
|
}
|
||||||
|
aria-pressed={shownEnvVars.has(key)}
|
||||||
|
title={
|
||||||
|
shownEnvVars.has(key) ? "Hide value" : "Show value"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shownEnvVars.has(key) ? (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-2"
|
||||||
|
onClick={() => {
|
||||||
|
const key = "";
|
||||||
|
const newEnv = { ...env };
|
||||||
|
newEnv[key] = "";
|
||||||
|
setEnv(newEnv);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Environment Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
|
className="flex items-center w-full"
|
||||||
|
data-testid="config-button"
|
||||||
|
>
|
||||||
|
{showConfig ? (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Configuration
|
||||||
|
</Button>
|
||||||
|
{showConfig && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(config).map(([key, configItem]) => {
|
||||||
|
const configKey = key as keyof InspectorConfig;
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="text-sm font-medium text-green-600">
|
||||||
|
{configKey}
|
||||||
|
</label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{configItem.description}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{typeof configItem.value === "number" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
data-testid={`${configKey}-input`}
|
||||||
|
value={configItem.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: Number(e.target.value),
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
) : typeof configItem.value === "boolean" ? (
|
||||||
|
<Select
|
||||||
|
data-testid={`${configKey}-select`}
|
||||||
|
value={configItem.value.toString()}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: val === "true",
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">True</SelectItem>
|
||||||
|
<SelectItem value="false">False</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
data-testid={`${configKey}-input`}
|
||||||
|
value={configItem.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig[configKey] = {
|
||||||
|
...configItem,
|
||||||
|
value: e.target.value,
|
||||||
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connectionStatus === "connected" && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button data-testid="connect-button" onClick={onConnect}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onDisconnect}>
|
||||||
|
<RefreshCwOff className="w-4 h-4 mr-2" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connectionStatus !== "connected" && (
|
||||||
|
<Button className="w-full" onClick={onConnect}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center space-x-2 mb-4">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${(() => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500";
|
||||||
|
case "error-connecting-to-proxy":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
})()}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{(() => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return "Connected";
|
||||||
|
case "error":
|
||||||
|
return "Connection Error, is your MCP server running?";
|
||||||
|
case "error-connecting-to-proxy":
|
||||||
|
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
|
||||||
|
default:
|
||||||
|
return "Disconnected";
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Error output from MCP server
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||||
|
{stdErrNotifications.map((notification, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
|
||||||
|
>
|
||||||
|
{notification.params.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Select
|
||||||
|
value={theme}
|
||||||
|
onValueChange={(value: string) =>
|
||||||
|
setTheme(value as "system" | "light" | "dark")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]" id="theme-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
|||||||
@@ -1,39 +1,119 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { ListToolsResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AlertCircle, Send } from "lucide-react";
|
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
||||||
import { useState } from "react";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
|
import {
|
||||||
|
CallToolResultSchema,
|
||||||
|
CompatibilityCallToolResult,
|
||||||
|
ListToolsResult,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
|
||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
listTools,
|
listTools,
|
||||||
|
clearTools,
|
||||||
callTool,
|
callTool,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
toolResult,
|
toolResult,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
error,
|
|
||||||
}: {
|
}: {
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
callTool: (name: string, params: Record<string, unknown>) => void;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: string;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
nextCursor: ListToolsResult["nextCursor"];
|
nextCursor: ListToolsResult["nextCursor"];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
setParams({});
|
||||||
|
}, [selectedTool]);
|
||||||
|
|
||||||
|
const renderToolResult = () => {
|
||||||
|
if (!toolResult) return null;
|
||||||
|
|
||||||
|
if ("content" in toolResult) {
|
||||||
|
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||||
|
if (!parsedResult.success) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||||
|
<JsonView data={toolResult} />
|
||||||
|
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||||
|
{parsedResult.error.errors.map((error, idx) => (
|
||||||
|
<JsonView data={error} key={idx} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const structuredResult = parsedResult.data;
|
||||||
|
const isError = structuredResult.isError ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">
|
||||||
|
Tool Result: {isError ? "Error" : "Success"}
|
||||||
|
</h4>
|
||||||
|
{structuredResult.content.map((item, index) => (
|
||||||
|
<div key={index} className="mb-2">
|
||||||
|
{item.type === "text" && <JsonView data={item.text} />}
|
||||||
|
{item.type === "image" && (
|
||||||
|
<img
|
||||||
|
src={`data:${item.mimeType};base64,${item.data}`}
|
||||||
|
alt="Tool result image"
|
||||||
|
className="max-w-full h-auto"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<JsonView data={item.resource} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if ("toolResult" in toolResult) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||||
|
|
||||||
|
<JsonView data={toolResult.toolResult} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
|
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
|
||||||
<ListPane
|
<ListPane
|
||||||
items={tools}
|
items={tools}
|
||||||
listItems={listTools}
|
listItems={listTools}
|
||||||
|
clearItems={() => {
|
||||||
|
clearTools();
|
||||||
|
setSelectedTool(null);
|
||||||
|
}}
|
||||||
setSelectedItem={setSelectedTool}
|
setSelectedItem={setSelectedTool}
|
||||||
renderItem={(tool) => (
|
renderItem={(tool) => (
|
||||||
<>
|
<>
|
||||||
@@ -48,66 +128,117 @@ const ToolsTab = ({
|
|||||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-card rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{error ? (
|
{selectedTool ? (
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : selectedTool ? (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{selectedTool.description}
|
{selectedTool.description}
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => (
|
([key, value]) => {
|
||||||
<div key={key}>
|
const prop = value as JsonSchemaType;
|
||||||
<Label
|
return (
|
||||||
htmlFor={key}
|
<div key={key}>
|
||||||
className="block text-sm font-medium text-gray-700"
|
<Label
|
||||||
>
|
htmlFor={key}
|
||||||
{key}
|
className="block text-sm font-medium text-gray-700"
|
||||||
</Label>
|
>
|
||||||
<Input
|
{key}
|
||||||
// @ts-expect-error value type is currently unknown
|
</Label>
|
||||||
type={value.type === "number" ? "number" : "text"}
|
{prop.type === "boolean" ? (
|
||||||
id={key}
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
name={key}
|
<Checkbox
|
||||||
// @ts-expect-error value type is currently unknown
|
id={key}
|
||||||
placeholder={value.description}
|
name={key}
|
||||||
onChange={(e) =>
|
checked={!!params[key]}
|
||||||
setParams({
|
onCheckedChange={(checked: boolean) =>
|
||||||
...params,
|
setParams({
|
||||||
[key]:
|
...params,
|
||||||
// @ts-expect-error value type is currently unknown
|
[key]: checked,
|
||||||
value.type === "number"
|
})
|
||||||
? Number(e.target.value)
|
}
|
||||||
: e.target.value,
|
/>
|
||||||
})
|
<label
|
||||||
}
|
htmlFor={key}
|
||||||
/>
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
</div>
|
>
|
||||||
),
|
{prop.description || "Toggle this option"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : prop.type === "string" ? (
|
||||||
|
<Textarea
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
placeholder={prop.description}
|
||||||
|
value={(params[key] as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
type={
|
||||||
|
prop.type === "number" || prop.type === "integer"
|
||||||
|
? "number"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
placeholder={prop.description}
|
||||||
|
value={(params[key] as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setParams({
|
||||||
|
...params,
|
||||||
|
[key]:
|
||||||
|
prop.type === "number" ||
|
||||||
|
prop.type === "integer"
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
<Button onClick={() => callTool(selectedTool.name, params)}>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4 mr-2" />
|
||||||
Run Tool
|
Run Tool
|
||||||
</Button>
|
</Button>
|
||||||
{toolResult && (
|
{toolResult && renderToolResult()}
|
||||||
<>
|
|
||||||
<h4 className="font-semibold mb-2">Tool Result:</h4>
|
|
||||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-auto max-h-64">
|
|
||||||
{toolResult}
|
|
||||||
</pre>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|||||||
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal file
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
419
client/src/components/__tests__/Sidebar.test.tsx
Normal file
419
client/src/components/__tests__/Sidebar.test.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
|
import Sidebar from "../Sidebar";
|
||||||
|
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// Mock theme hook
|
||||||
|
jest.mock("../../lib/useTheme", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => ["light", jest.fn()],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Sidebar Environment Variables", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
connectionStatus: "disconnected" as const,
|
||||||
|
transportType: "stdio" as const,
|
||||||
|
setTransportType: jest.fn(),
|
||||||
|
command: "",
|
||||||
|
setCommand: jest.fn(),
|
||||||
|
args: "",
|
||||||
|
setArgs: jest.fn(),
|
||||||
|
sseUrl: "",
|
||||||
|
setSseUrl: jest.fn(),
|
||||||
|
env: {},
|
||||||
|
setEnv: jest.fn(),
|
||||||
|
bearerToken: "",
|
||||||
|
setBearerToken: jest.fn(),
|
||||||
|
onConnect: jest.fn(),
|
||||||
|
onDisconnect: jest.fn(),
|
||||||
|
stdErrNotifications: [],
|
||||||
|
logLevel: "info" as const,
|
||||||
|
sendLogLevelRequest: jest.fn(),
|
||||||
|
loggingSupported: true,
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
setConfig: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSidebar = (props = {}) => {
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} {...props} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEnvVarsSection = () => {
|
||||||
|
const button = screen.getByTestId("env-vars-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Basic Operations", () => {
|
||||||
|
it("should add a new environment variable", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
renderSidebar({ env: {}, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const addButton = screen.getByText("Add Environment Variable");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "": "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove an environment variable", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const removeButton = screen.getByRole("button", { name: "×" });
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update environment variable value", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
fireEvent.change(valueInput, { target: { value: "new_value" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle value visibility", () => {
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(valueInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
expect(valueInput).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Key Editing", () => {
|
||||||
|
it("should maintain order when editing first key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
|
||||||
|
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
NEW_FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order when editing middle key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
|
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
NEW_SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order when editing last key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
THIRD_KEY: "third_value",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const lastKeyInput = screen.getByDisplayValue("THIRD_KEY");
|
||||||
|
fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
NEW_THIRD_KEY: "third_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain order during key editing", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
KEY1: "value1",
|
||||||
|
KEY2: "value2",
|
||||||
|
};
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// Type "NEW_" one character at a time
|
||||||
|
const key1Input = screen.getByDisplayValue("KEY1");
|
||||||
|
"NEW_".split("").forEach((char) => {
|
||||||
|
fireEvent.change(key1Input, {
|
||||||
|
target: { value: char + "KEY1".slice(1) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the last setEnv call maintains the order
|
||||||
|
const lastCall = setEnv.mock.calls[
|
||||||
|
setEnv.mock.calls.length - 1
|
||||||
|
][0] as Record<string, string>;
|
||||||
|
const entries = Object.entries(lastCall);
|
||||||
|
|
||||||
|
// The values should stay with their original keys
|
||||||
|
expect(entries[0][1]).toBe("value1"); // First entry should still have value1
|
||||||
|
expect(entries[1][1]).toBe("value2"); // Second entry should still have value2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple Operations", () => {
|
||||||
|
it("should maintain state after multiple key edits", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = {
|
||||||
|
FIRST_KEY: "first_value",
|
||||||
|
SECOND_KEY: "second_value",
|
||||||
|
};
|
||||||
|
const { rerender } = renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// First key edit
|
||||||
|
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
|
||||||
|
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
|
||||||
|
|
||||||
|
// Get the updated env from the first setEnv call
|
||||||
|
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
|
||||||
|
|
||||||
|
// Rerender with the updated env
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second key edit
|
||||||
|
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
|
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
||||||
|
|
||||||
|
// Verify the final state matches what we expect
|
||||||
|
expect(setEnv).toHaveBeenLastCalledWith({
|
||||||
|
NEW_FIRST_KEY: "first_value",
|
||||||
|
NEW_SECOND_KEY: "second_value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain visibility state after key edit", () => {
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
const { rerender } = renderSidebar({ env: initialEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
// Show the value
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
||||||
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(valueInput).toHaveProperty("type", "text");
|
||||||
|
|
||||||
|
// Edit the key
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
|
||||||
|
|
||||||
|
// Rerender with updated env
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Value should still be visible
|
||||||
|
const updatedValueInput = screen.getByDisplayValue("test_value");
|
||||||
|
expect(updatedValueInput).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases", () => {
|
||||||
|
it("should handle empty key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle special characters in key", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unicode characters", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "TEST_🔑" } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very long key names", () => {
|
||||||
|
const setEnv = jest.fn();
|
||||||
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
||||||
|
openEnvVarsSection();
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("TEST_KEY");
|
||||||
|
const longKey = "A".repeat(100);
|
||||||
|
fireEvent.change(keyInput, { target: { value: longKey } });
|
||||||
|
|
||||||
|
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Configuration Operations", () => {
|
||||||
|
const openConfigSection = () => {
|
||||||
|
const button = screen.getByTestId("config-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update MCP server request timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 5000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid timeout values entered by user", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain configuration state after multiple updates", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
setConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
// First update
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
// Get the updated config from the first setConfig call
|
||||||
|
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
|
||||||
|
|
||||||
|
// Rerender with the updated config
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
config={updatedConfig}
|
||||||
|
setConfig={setConfig}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second update
|
||||||
|
const updatedTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
||||||
|
|
||||||
|
// Verify the final state matches what we expect
|
||||||
|
expect(setConfig).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 3000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
client/src/components/__tests__/ToolsTab.test.tsx
Normal file
102
client/src/components/__tests__/ToolsTab.test.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
const Alert = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -29,8 +29,8 @@ const Alert = React.forwardRef<
|
|||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Alert.displayName = "Alert"
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
const AlertTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@@ -41,8 +41,8 @@ const AlertTitle = React.forwardRef<
|
|||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertTitle.displayName = "AlertTitle"
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
const AlertDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@@ -53,7 +53,7 @@ const AlertDescription = React.forwardRef<
|
|||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDescription.displayName = "AlertDescription"
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
@@ -31,27 +31,27 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
97
client/src/components/ui/combobox.tsx
Normal file
97
client/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
interface ComboboxProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onInputChange: (value: string) => void;
|
||||||
|
options: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onInputChange,
|
||||||
|
options = [],
|
||||||
|
placeholder = "Select...",
|
||||||
|
emptyMessage = "No results found.",
|
||||||
|
id,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleSelect = React.useCallback(
|
||||||
|
(option: string) => {
|
||||||
|
onChange(option);
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onInputChange(value);
|
||||||
|
},
|
||||||
|
[onInputChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{value || placeholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command shouldFilter={false} id={id}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onValueChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
onSelect={() => handleSelect(option)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === option ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{option}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
client/src/components/ui/command.tsx
Normal file
150
client/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
121
client/src/components/ui/dialog.tsx
Normal file
121
client/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
@@ -12,14 +12,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
)
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
|
|||||||
className={cn(labelVariants(), className)}
|
className={cn(labelVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
31
client/src/components/ui/popover.tsx
Normal file
31
client/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
CaretSortIcon,
|
CaretSortIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
} from "@radix-ui/react-icons"
|
} from "@radix-ui/react-icons";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
@@ -22,8 +22,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 hover:border-[#646cff] hover:border-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -32,8 +32,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
@@ -43,14 +43,14 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon />
|
<ChevronUpIcon />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
@@ -60,15 +60,15 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
@@ -81,7 +81,7 @@ const SelectContent = React.forwardRef<
|
|||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -91,7 +91,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -99,8 +99,8 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
@@ -111,8 +111,8 @@ const SelectLabel = React.forwardRef<
|
|||||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
@@ -122,7 +122,7 @@ const SelectItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -133,8 +133,8 @@ const SelectItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
@@ -145,8 +145,8 @@ const SelectSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
@@ -159,4 +159,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
const TabsList = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
const TabsTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
@@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-muted data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
const TabsContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export interface TextareaProps
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
126
client/src/components/ui/toast.tsx
Normal file
126
client/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
33
client/src/components/ui/toaster.tsx
Normal file
33
client/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/src/components/ui/tooltip.tsx
Normal file
30
client/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
191
client/src/hooks/use-toast.ts
Normal file
191
client/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
@@ -38,25 +38,6 @@ h1 {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
@@ -65,9 +46,6 @@ button:focus-visible {
|
|||||||
a:hover {
|
a:hover {
|
||||||
color: #747bff;
|
color: #747bff;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
73
client/src/lib/auth.ts
Normal file
73
client/src/lib/auth.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import {
|
||||||
|
OAuthClientInformationSchema,
|
||||||
|
OAuthClientInformation,
|
||||||
|
OAuthTokens,
|
||||||
|
OAuthTokensSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { SESSION_KEYS } from "./constants";
|
||||||
|
|
||||||
|
class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
|
get redirectUrl() {
|
||||||
|
return window.location.origin + "/oauth/callback";
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientMetadata() {
|
||||||
|
return {
|
||||||
|
redirect_uris: [this.redirectUrl],
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
grant_types: ["authorization_code", "refresh_token"],
|
||||||
|
response_types: ["code"],
|
||||||
|
client_name: "MCP Inspector",
|
||||||
|
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async clientInformation() {
|
||||||
|
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
|
JSON.stringify(clientInformation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokens() {
|
||||||
|
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
|
||||||
|
if (!tokens) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTokens(tokens: OAuthTokens) {
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectToAuthorization(authorizationUrl: URL) {
|
||||||
|
window.location.href = authorizationUrl.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCodeVerifier(codeVerifier: string) {
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier() {
|
||||||
|
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
||||||
|
if (!verifier) {
|
||||||
|
throw new Error("No code verifier saved for session");
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authProvider = new InspectorOAuthClientProvider();
|
||||||
19
client/src/lib/configurationTypes.ts
Normal file
19
client/src/lib/configurationTypes.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type ConfigItem = {
|
||||||
|
description: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration interface for the MCP Inspector, including settings for the MCP Client,
|
||||||
|
* Proxy Server, and Inspector UI/UX.
|
||||||
|
*
|
||||||
|
* Note: Configuration related to which MCP Server to use or any other MCP Server
|
||||||
|
* specific settings are outside the scope of this interface as of now.
|
||||||
|
*/
|
||||||
|
export type InspectorConfig = {
|
||||||
|
/**
|
||||||
|
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
|
||||||
|
*/
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
|
||||||
|
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
||||||
|
};
|
||||||
33
client/src/lib/constants.ts
Normal file
33
client/src/lib/constants.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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 type ConnectionStatus =
|
||||||
|
| "disconnected"
|
||||||
|
| "connected"
|
||||||
|
| "error"
|
||||||
|
| "error-connecting-to-proxy";
|
||||||
|
|
||||||
|
export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser.
|
||||||
|
* Future plans: Provide json config file + Browser local_storage to override default values
|
||||||
|
**/
|
||||||
|
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 10000,
|
||||||
|
},
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
description:
|
||||||
|
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
128
client/src/lib/hooks/useCompletionState.ts
Normal file
128
client/src/lib/hooks/useCompletionState.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
ResourceReference,
|
||||||
|
PromptReference,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
|
interface CompletionState {
|
||||||
|
completions: Record<string, string[]>;
|
||||||
|
loading: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
|
||||||
|
func: T,
|
||||||
|
wait: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return function (...args: Parameters<T>) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletionState(
|
||||||
|
handleCompletion: (
|
||||||
|
ref: ResourceReference | PromptReference,
|
||||||
|
argName: string,
|
||||||
|
value: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => Promise<string[]>,
|
||||||
|
completionsSupported: boolean = true,
|
||||||
|
debounceMs: number = 300,
|
||||||
|
) {
|
||||||
|
const [state, setState] = useState<CompletionState>({
|
||||||
|
completions: {},
|
||||||
|
loading: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return cleanup;
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
const clearCompletions = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setState({
|
||||||
|
completions: {},
|
||||||
|
loading: {},
|
||||||
|
});
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
const requestCompletions = useCallback(
|
||||||
|
debounce(
|
||||||
|
async (
|
||||||
|
ref: ResourceReference | PromptReference,
|
||||||
|
argName: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (!completionsSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: { ...prev.loading, [argName]: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await handleCompletion(
|
||||||
|
ref,
|
||||||
|
argName,
|
||||||
|
value,
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
completions: { ...prev.completions, [argName]: values },
|
||||||
|
loading: { ...prev.loading, [argName]: false },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: { ...prev.loading, [argName]: false },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (abortControllerRef.current === abortController) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
debounceMs,
|
||||||
|
),
|
||||||
|
[handleCompletion, completionsSupported, cleanup, debounceMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear completions when support status changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!completionsSupported) {
|
||||||
|
clearCompletions();
|
||||||
|
}
|
||||||
|
}, [completionsSupported, clearCompletions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
clearCompletions,
|
||||||
|
requestCompletions,
|
||||||
|
completionsSupported,
|
||||||
|
};
|
||||||
|
}
|
||||||
382
client/src/lib/hooks/useConnection.ts
Normal file
382
client/src/lib/hooks/useConnection.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ConnectionStatus, 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<ConnectionStatus>("disconnected");
|
||||||
|
const { toast } = useToast();
|
||||||
|
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({
|
||||||
|
title: "Error",
|
||||||
|
description: errorString,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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({
|
||||||
|
title: "Error",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendNotification = async (notification: ClientNotification) => {
|
||||||
|
if (!mcpClient) {
|
||||||
|
const error = new Error("MCP client not connected");
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
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({
|
||||||
|
title: "Error",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkProxyHealth = async () => {
|
||||||
|
try {
|
||||||
|
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
|
||||||
|
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
||||||
|
const proxyHealth = await proxyHealthResponse.json();
|
||||||
|
if (proxyHealth?.status !== "ok") {
|
||||||
|
throw new Error("MCP Proxy Server is not healthy");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Couldn't connect to MCP Proxy Server", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const client = new Client<Request, Notification, Result>(
|
||||||
|
{
|
||||||
|
name: "mcp-inspector",
|
||||||
|
version: packageJson.version,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
sampling: {},
|
||||||
|
roots: {
|
||||||
|
listChanged: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkProxyHealth();
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus("error-connecting-to-proxy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
|
||||||
|
mcpProxyServerUrl.searchParams.append("transportType", transportType);
|
||||||
|
if (transportType === "stdio") {
|
||||||
|
mcpProxyServerUrl.searchParams.append("command", command);
|
||||||
|
mcpProxyServerUrl.searchParams.append("args", args);
|
||||||
|
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||||
|
} else {
|
||||||
|
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||||
|
// 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(mcpProxyServerUrl, {
|
||||||
|
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 via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
client/src/lib/hooks/useDraggablePane.ts
Normal file
53
client/src/lib/hooks/useDraggablePane.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useDraggablePane(initialHeight: number) {
|
||||||
|
const [height, setHeight] = useState(initialHeight);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragStartY = useRef<number>(0);
|
||||||
|
const dragStartHeight = useRef<number>(0);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStartY.current = e.clientY;
|
||||||
|
dragStartHeight.current = height;
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
},
|
||||||
|
[height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const deltaY = dragStartY.current - e.clientY;
|
||||||
|
const newHeight = Math.max(
|
||||||
|
100,
|
||||||
|
Math.min(800, dragStartHeight.current + deltaY),
|
||||||
|
);
|
||||||
|
setHeight(newHeight);
|
||||||
|
},
|
||||||
|
[isDragging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleDragMove);
|
||||||
|
window.addEventListener("mouseup", handleDragEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleDragMove);
|
||||||
|
window.removeEventListener("mouseup", handleDragEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging, handleDragMove, handleDragEnd]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
isDragging,
|
||||||
|
handleDragStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
client/src/lib/notificationTypes.ts
Normal file
22
client/src/lib/notificationTypes.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
NotificationSchema as BaseNotificationSchema,
|
||||||
|
ClientNotificationSchema,
|
||||||
|
ServerNotificationSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
|
||||||
|
method: z.literal("notifications/stderr"),
|
||||||
|
params: z.object({
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NotificationSchema = ClientNotificationSchema.or(
|
||||||
|
StdErrNotificationSchema,
|
||||||
|
)
|
||||||
|
.or(ServerNotificationSchema)
|
||||||
|
.or(BaseNotificationSchema);
|
||||||
|
|
||||||
|
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
||||||
|
export type Notification = z.infer<typeof NotificationSchema>;
|
||||||
49
client/src/lib/useTheme.ts
Normal file
49
client/src/lib/useTheme.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const useTheme = (): [Theme, (mode: Theme) => void] => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||||
|
return savedTheme || "system";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const darkModeMediaQuery = window.matchMedia(
|
||||||
|
"(prefers-color-scheme: dark)",
|
||||||
|
);
|
||||||
|
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
||||||
|
if (theme === "system") {
|
||||||
|
updateDocumentTheme(e.matches ? "dark" : "light");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocumentTheme = (newTheme: "light" | "dark") => {
|
||||||
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial theme based on current mode
|
||||||
|
if (theme === "system") {
|
||||||
|
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
|
||||||
|
} else {
|
||||||
|
updateDocumentTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Toaster } from "@/components/ui/toaster.tsx";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { TooltipProvider } from "./components/ui/tooltip.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
27
client/src/utils/__tests__/escapeUnicode.test.ts
Normal file
27
client/src/utils/__tests__/escapeUnicode.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { escapeUnicode } from "../escapeUnicode";
|
||||||
|
|
||||||
|
describe("escapeUnicode", () => {
|
||||||
|
it("should escape Unicode characters in a string", () => {
|
||||||
|
const input = { text: "你好世界" };
|
||||||
|
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
|
||||||
|
expect(escapeUnicode(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty strings", () => {
|
||||||
|
const input = { text: "" };
|
||||||
|
const expected = '{\n "text": ""\n}';
|
||||||
|
expect(escapeUnicode(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null and undefined values", () => {
|
||||||
|
const input = { text: null, value: undefined };
|
||||||
|
const expected = '{\n "text": null\n}';
|
||||||
|
expect(escapeUnicode(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle numbers and booleans", () => {
|
||||||
|
const input = { number: 123, boolean: true };
|
||||||
|
const expected = '{\n "number": 123,\n "boolean": true\n}';
|
||||||
|
expect(escapeUnicode(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
|
||||||
|
import { JsonValue } from "../../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
describe("updateValueAtPath", () => {
|
||||||
|
// Basic functionality tests
|
||||||
|
test("returns the new value when path is empty", () => {
|
||||||
|
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
||||||
|
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
||||||
|
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
|
||||||
|
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Object update tests
|
||||||
|
test("updates a simple object property", () => {
|
||||||
|
const obj = { name: "John", age: 30 };
|
||||||
|
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
|
||||||
|
name: "John",
|
||||||
|
age: 31,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a nested object property", () => {
|
||||||
|
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||||
|
expect(
|
||||||
|
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||||
|
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates missing object properties", () => {
|
||||||
|
const obj = { user: { name: "John" } };
|
||||||
|
expect(
|
||||||
|
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||||
|
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Array update tests
|
||||||
|
test("updates an array item", () => {
|
||||||
|
const arr = [1, 2, 3, 4];
|
||||||
|
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extends an array when index is out of bounds", () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, 2, 3, null, null, "new"]);
|
||||||
|
|
||||||
|
// Explicitly verify that indices 3 and 4 contain null, not undefined
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
|
||||||
|
// Verify these aren't "holes" in the array (important distinction)
|
||||||
|
expect(3 in result).toBe(true);
|
||||||
|
expect(4 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify the array has the correct length
|
||||||
|
expect(result.length).toBe(6);
|
||||||
|
|
||||||
|
// Verify the array doesn't have holes by checking every index exists
|
||||||
|
expect(result.every((_, index: number) => index in result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a nested array item", () => {
|
||||||
|
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||||
|
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
|
||||||
|
users: [{ name: "John" }, { name: "Janet" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling tests
|
||||||
|
test("returns original value when trying to update a primitive with a path", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const result = updateValueAtPath("string", ["foo"], "bar");
|
||||||
|
expect(result).toBe("string");
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns original array when index is invalid", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns original array when index is negative", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles sparse arrays correctly by filling holes with null", () => {
|
||||||
|
// Create a sparse array by deleting an element
|
||||||
|
const sparseArr = [1, 2, 3];
|
||||||
|
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
|
||||||
|
|
||||||
|
// Update a value beyond the array length
|
||||||
|
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, null, 3, null, null, "new"]);
|
||||||
|
|
||||||
|
// Explicitly verify that index 1 (the hole) contains null, not undefined
|
||||||
|
expect(result[1]).toBe(null);
|
||||||
|
|
||||||
|
// Verify this isn't a hole in the array
|
||||||
|
expect(1 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify all indices contain null (not undefined)
|
||||||
|
expect(result[1]).not.toBe(undefined);
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getValueAtPath", () => {
|
||||||
|
test("returns the original value when path is empty", () => {
|
||||||
|
const obj = { foo: "bar" };
|
||||||
|
expect(getValueAtPath(obj, [])).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the value at a simple path", () => {
|
||||||
|
const obj = { name: "John", age: 30 };
|
||||||
|
expect(getValueAtPath(obj, ["name"])).toBe("John");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the value at a nested path", () => {
|
||||||
|
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||||
|
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value when path does not exist", () => {
|
||||||
|
const obj = { user: { name: "John" } };
|
||||||
|
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
|
||||||
|
"Unknown",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value when input is null/undefined", () => {
|
||||||
|
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
|
||||||
|
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles array indices correctly", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["1"])).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value for out of bounds array indices", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value for invalid array indices", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates through mixed object and array paths", () => {
|
||||||
|
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||||
|
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
|
||||||
|
});
|
||||||
|
});
|
||||||
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||||
|
import { JsonSchemaType } from "../../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
describe("generateDefaultValue", () => {
|
||||||
|
test("generates default string", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default number", () => {
|
||||||
|
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default integer", () => {
|
||||||
|
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default boolean", () => {
|
||||||
|
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default array", () => {
|
||||||
|
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default empty object", () => {
|
||||||
|
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default null for unknown types", () => {
|
||||||
|
// @ts-expect-error Testing with invalid type
|
||||||
|
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates empty array for non-required array", () => {
|
||||||
|
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates empty object for non-required object", () => {
|
||||||
|
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates null for non-required primitive types", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates object with properties", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", required: true },
|
||||||
|
age: { type: "number", required: true },
|
||||||
|
isActive: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
name: "",
|
||||||
|
age: 0,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles nested objects", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", required: true },
|
||||||
|
address: {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
user: {
|
||||||
|
name: "",
|
||||||
|
address: {
|
||||||
|
city: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses schema default value when provided", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
|
||||||
|
"test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatFieldLabel", () => {
|
||||||
|
test("formats camelCase", () => {
|
||||||
|
expect(formatFieldLabel("firstName")).toBe("First Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats snake_case", () => {
|
||||||
|
expect(formatFieldLabel("first_name")).toBe("First name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats single word", () => {
|
||||||
|
expect(formatFieldLabel("name")).toBe("Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats mixed case with underscores", () => {
|
||||||
|
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty string", () => {
|
||||||
|
expect(formatFieldLabel("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
client/src/utils/configUtils.ts
Normal file
14
client/src/utils/configUtils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { InspectorConfig } from "@/lib/configurationTypes";
|
||||||
|
import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||||
|
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
||||||
|
if (proxyFullAddress) {
|
||||||
|
return proxyFullAddress;
|
||||||
|
}
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||||
|
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
|
||||||
|
};
|
||||||
16
client/src/utils/escapeUnicode.ts
Normal file
16
client/src/utils/escapeUnicode.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Utility function to escape Unicode characters
|
||||||
|
export function escapeUnicode(obj: unknown): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
obj,
|
||||||
|
(_key: string, value) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// Replace non-ASCII characters with their Unicode escape sequences
|
||||||
|
return value.replace(/[^\0-\x7F]/g, (char) => {
|
||||||
|
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
149
client/src/utils/jsonPathUtils.ts
Normal file
149
client/src/utils/jsonPathUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { JsonValue } from "../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a value at a specific path in a nested JSON structure
|
||||||
|
* @param obj The original JSON value
|
||||||
|
* @param path Array of keys/indices representing the path to the value
|
||||||
|
* @param value The new value to set
|
||||||
|
* @returns A new JSON value with the updated path
|
||||||
|
*/
|
||||||
|
export function updateValueAtPath(
|
||||||
|
obj: JsonValue,
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonValue {
|
||||||
|
if (path.length === 0) return value;
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
obj = !isNaN(Number(path[0])) ? [] : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return updateArray(obj, path, value);
|
||||||
|
} else if (typeof obj === "object" && obj !== null) {
|
||||||
|
return updateObject(obj as JsonObject, path, value);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
||||||
|
obj,
|
||||||
|
);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an array at a specific path
|
||||||
|
*/
|
||||||
|
function updateArray(
|
||||||
|
array: JsonValue[],
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonValue[] {
|
||||||
|
const [index, ...restPath] = path;
|
||||||
|
const arrayIndex = Number(index);
|
||||||
|
|
||||||
|
if (isNaN(arrayIndex)) {
|
||||||
|
console.error(`Invalid array index: ${index}`);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrayIndex < 0) {
|
||||||
|
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newArray: JsonValue[] = [];
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
newArray[i] = i in array ? array[i] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrayIndex >= newArray.length) {
|
||||||
|
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
|
||||||
|
// Copy over the existing elements (now guaranteed to be dense)
|
||||||
|
for (let i = 0; i < newArray.length; i++) {
|
||||||
|
extendedArray[i] = newArray[i];
|
||||||
|
}
|
||||||
|
newArray = extendedArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restPath.length === 0) {
|
||||||
|
newArray[arrayIndex] = value;
|
||||||
|
} else {
|
||||||
|
newArray[arrayIndex] = updateValueAtPath(
|
||||||
|
newArray[arrayIndex],
|
||||||
|
restPath,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an object at a specific path
|
||||||
|
*/
|
||||||
|
function updateObject(
|
||||||
|
obj: JsonObject,
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonObject {
|
||||||
|
const [key, ...restPath] = path;
|
||||||
|
|
||||||
|
// Validate object key
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
console.error(`Invalid object key: ${key}`);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newObj = { ...obj };
|
||||||
|
|
||||||
|
if (restPath.length === 0) {
|
||||||
|
newObj[key] = value;
|
||||||
|
} else {
|
||||||
|
// Ensure key exists
|
||||||
|
if (!(key in newObj)) {
|
||||||
|
newObj[key] = {};
|
||||||
|
}
|
||||||
|
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
|
||||||
|
}
|
||||||
|
return newObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a value at a specific path in a nested JSON structure
|
||||||
|
* @param obj The JSON value to traverse
|
||||||
|
* @param path Array of keys/indices representing the path to the value
|
||||||
|
* @param defaultValue Value to return if path doesn't exist
|
||||||
|
* @returns The value at the path, or defaultValue if not found
|
||||||
|
*/
|
||||||
|
export function getValueAtPath(
|
||||||
|
obj: JsonValue,
|
||||||
|
path: string[],
|
||||||
|
defaultValue: JsonValue = null,
|
||||||
|
): JsonValue {
|
||||||
|
if (path.length === 0) return obj;
|
||||||
|
|
||||||
|
const [first, ...rest] = path;
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const index = Number(first);
|
||||||
|
if (isNaN(index) || index < 0 || index >= obj.length) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return getValueAtPath(obj[index], rest, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === "object" && obj !== null) {
|
||||||
|
if (!(first in obj)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
57
client/src/utils/schemaUtils.ts
Normal file
57
client/src/utils/schemaUtils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
|
||||||
|
import { JsonObject } from "./jsonPathUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a default value based on a JSON schema type
|
||||||
|
* @param schema The JSON schema definition
|
||||||
|
* @returns A default value matching the schema type, or null for non-required fields
|
||||||
|
*/
|
||||||
|
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
||||||
|
if ("default" in schema) {
|
||||||
|
return schema.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.required) {
|
||||||
|
if (schema.type === "array") return [];
|
||||||
|
if (schema.type === "object") return {};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case "string":
|
||||||
|
return "";
|
||||||
|
case "number":
|
||||||
|
case "integer":
|
||||||
|
return 0;
|
||||||
|
case "boolean":
|
||||||
|
return false;
|
||||||
|
case "array":
|
||||||
|
return [];
|
||||||
|
case "object": {
|
||||||
|
if (!schema.properties) return {};
|
||||||
|
|
||||||
|
const obj: JsonObject = {};
|
||||||
|
Object.entries(schema.properties)
|
||||||
|
.filter(([, prop]) => prop.required)
|
||||||
|
.forEach(([key, prop]) => {
|
||||||
|
const value = generateDefaultValue(prop);
|
||||||
|
obj[key] = value;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a field key into a human-readable label
|
||||||
|
* @param key The field key to format
|
||||||
|
* @returns A formatted label string
|
||||||
|
*/
|
||||||
|
export function formatFieldLabel(key: string): string {
|
||||||
|
return key
|
||||||
|
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
|
||||||
|
.replace(/_/g, " ") // Replace underscores with spaces
|
||||||
|
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
import animate from "tailwindcss-animate";
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
@@ -53,5 +54,5 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [animate],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./src/*"]
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
@@ -25,7 +23,9 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["jest", "@testing-library/jest-dom", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
10
client/tsconfig.jest.json
Normal file
10
client/tsconfig.jest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
mcp-inspector.png
Normal file
BIN
mcp-inspector.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
10389
package-lock.json
generated
10389
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -1,24 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"private": true,
|
"version": "0.8.1",
|
||||||
"version": "0.0.1",
|
"description": "Model Context Protocol inspector",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
"homepage": "https://modelcontextprotocol.io",
|
||||||
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcp-inspector": "./bin/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"client/bin",
|
||||||
|
"client/dist",
|
||||||
|
"server/build"
|
||||||
|
],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
"server"
|
"server"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
|
||||||
|
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
|
||||||
|
"test": "npm run prettier-check && cd client && npm test",
|
||||||
"build-server": "cd server && npm run build",
|
"build-server": "cd server && npm run build",
|
||||||
"build-client": "cd client && npm run build",
|
"build-client": "cd client && npm run build",
|
||||||
"build": "npm run build-server && npm run build-client",
|
"build": "npm run build-server && npm run build-client",
|
||||||
"start-server": "cd server && npm run start",
|
"start-server": "cd server && npm run start",
|
||||||
"start-client": "cd client && npm run preview",
|
"start-client": "cd client && npm run preview",
|
||||||
"start": "concurrently \"npm run start-server\" \"npm run start-client\"",
|
"start": "node ./bin/cli.js",
|
||||||
"prettier-fix": "prettier --write ."
|
"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.8.1",
|
||||||
|
"@modelcontextprotocol/inspector-server": "^0.8.1",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"shell-quote": "^1.8.2",
|
||||||
|
"spawn-rx": "^5.1.2",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.0.1",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"prettier": "3.3.3"
|
"prettier": "3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-inspector",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.0.1",
|
"version": "0.8.1",
|
||||||
"main": "build/index.js",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
"homepage": "https://modelcontextprotocol.io",
|
||||||
|
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcp-inspector-server": "build/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node build/index.js",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/eventsource": "^1.1.15",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "*",
|
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"eventsource": "^2.0.2",
|
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -1,39 +1,97 @@
|
|||||||
import cors from "cors";
|
#!/usr/bin/env node
|
||||||
import EventSource from "eventsource";
|
|
||||||
|
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
import cors from "cors";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
import { parseArgs } from "node:util";
|
||||||
|
import { parse as shellParseArgs } from "shell-quote";
|
||||||
|
|
||||||
|
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 { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { findActualExecutable } from "spawn-rx";
|
||||||
import mcpProxy from "./mcpProxy.js";
|
import mcpProxy from "./mcpProxy.js";
|
||||||
|
|
||||||
// Polyfill EventSource for an SSE client in Node.js
|
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(global as any).EventSource = EventSource;
|
const defaultEnvironment = {
|
||||||
|
...getDefaultEnvironment(),
|
||||||
|
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
env: { type: "string", default: "" },
|
||||||
|
args: { type: "string", default: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
let webAppTransports: SSEServerTransport[] = [];
|
let webAppTransports: SSEServerTransport[] = [];
|
||||||
|
|
||||||
const createTransport = async (query: express.Request["query"]) => {
|
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
|
const query = req.query;
|
||||||
console.log("Query parameters:", query);
|
console.log("Query parameters:", query);
|
||||||
|
|
||||||
const transportType = query.transportType as string;
|
const transportType = query.transportType as string;
|
||||||
|
|
||||||
if (transportType === "stdio") {
|
if (transportType === "stdio") {
|
||||||
const command = query.command as string;
|
const command = query.command as string;
|
||||||
const args = (query.args as string).split(/\s+/);
|
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||||
console.log(`Stdio transport: command=${command}, args=${args}`);
|
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||||
const transport = new StdioClientTransport({ command, args });
|
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
|
||||||
|
|
||||||
|
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||||
|
|
||||||
|
console.log(`Stdio transport: command=${cmd}, args=${args}`);
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: cmd,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
||||||
console.log("Spawned stdio transport");
|
console.log("Spawned stdio transport");
|
||||||
return transport;
|
return transport;
|
||||||
} else if (transportType === "sse") {
|
} else if (transportType === "sse") {
|
||||||
const url = query.url as string;
|
const url = query.url as string;
|
||||||
console.log(`SSE transport: url=${url}`);
|
const headers: HeadersInit = {
|
||||||
const transport = new SSEClientTransport(new URL(url));
|
Accept: "text/event-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||||
|
if (req.headers[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = req.headers[key];
|
||||||
|
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(new URL(url), {
|
||||||
|
eventSourceInit: {
|
||||||
|
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||||
|
},
|
||||||
|
requestInit: {
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
await transport.start();
|
await transport.start();
|
||||||
|
|
||||||
console.log("Connected to SSE transport");
|
console.log("Connected to SSE transport");
|
||||||
return transport;
|
return transport;
|
||||||
} else {
|
} else {
|
||||||
@@ -42,44 +100,109 @@ const createTransport = async (query: express.Request["query"]) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let backingServerTransport: Transport | undefined;
|
||||||
|
|
||||||
app.get("/sse", async (req, res) => {
|
app.get("/sse", async (req, res) => {
|
||||||
console.log("New SSE connection");
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Connected MCP client to backing server transport");
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const webAppTransport = new SSEServerTransport("/message", res);
|
console.log("Connected MCP client to backing server transport");
|
||||||
console.log("Created web app transport");
|
|
||||||
|
|
||||||
webAppTransports.push(webAppTransport);
|
const webAppTransport = new SSEServerTransport("/message", res);
|
||||||
console.log("Created web app transport");
|
console.log("Created web app transport");
|
||||||
|
|
||||||
await webAppTransport.start();
|
webAppTransports.push(webAppTransport);
|
||||||
|
console.log("Created web app transport");
|
||||||
|
|
||||||
mcpProxy({
|
await webAppTransport.start();
|
||||||
transportToClient: webAppTransport,
|
|
||||||
transportToServer: backingServerTransport,
|
if (backingServerTransport instanceof StdioClientTransport) {
|
||||||
onerror: (error) => {
|
backingServerTransport.stderr!.on("data", (chunk) => {
|
||||||
console.error(error);
|
webAppTransport.send({
|
||||||
},
|
jsonrpc: "2.0",
|
||||||
});
|
method: "notifications/stderr",
|
||||||
console.log("Set up MCP proxy");
|
params: {
|
||||||
|
content: chunk.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpProxy({
|
||||||
|
transportToClient: webAppTransport,
|
||||||
|
transportToServer: backingServerTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Set up MCP proxy");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /sse route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/message", async (req, res) => {
|
app.post("/message", async (req, res) => {
|
||||||
const sessionId = req.query.sessionId;
|
try {
|
||||||
console.log(`Received message for sessionId ${sessionId}`);
|
const sessionId = req.query.sessionId;
|
||||||
|
console.log(`Received message for sessionId ${sessionId}`);
|
||||||
|
|
||||||
const transport = webAppTransports.find((t) => t.sessionId === sessionId);
|
const transport = webAppTransports.find((t) => t.sessionId === sessionId);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
res.status(404).send("Session not found");
|
res.status(404).end("Session not found");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /message route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
}
|
}
|
||||||
await transport.handlePostMessage(req, res);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
app.get("/health", (req, res) => {
|
||||||
app.listen(PORT, () => {
|
res.json({
|
||||||
console.log(`Server is running on port ${PORT}`);
|
status: "ok",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/config", (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
defaultEnvironment,
|
||||||
|
defaultCommand: values.env,
|
||||||
|
defaultArgs: values.args,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /config route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
|
||||||
|
function onClientError(error: Error) {
|
||||||
|
console.error("Error from inspector client:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onServerError(error: Error) {
|
||||||
|
console.error("Error from MCP server:", error);
|
||||||
|
}
|
||||||
|
|
||||||
export default function mcpProxy({
|
export default function mcpProxy({
|
||||||
transportToClient,
|
transportToClient,
|
||||||
transportToServer,
|
transportToServer,
|
||||||
onerror,
|
|
||||||
}: {
|
}: {
|
||||||
transportToClient: Transport;
|
transportToClient: Transport;
|
||||||
transportToServer: Transport;
|
transportToServer: Transport;
|
||||||
onerror: (error: Error) => void;
|
|
||||||
}) {
|
}) {
|
||||||
let transportToClientClosed = false;
|
let transportToClientClosed = false;
|
||||||
let transportToServerClosed = false;
|
let transportToServerClosed = false;
|
||||||
|
|
||||||
transportToClient.onmessage = (message) => {
|
transportToClient.onmessage = (message) => {
|
||||||
transportToServer.send(message).catch(onerror);
|
transportToServer.send(message).catch(onServerError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToServer.onmessage = (message) => {
|
transportToServer.onmessage = (message) => {
|
||||||
transportToClient.send(message).catch(onerror);
|
transportToClient.send(message).catch(onClientError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToClient.onclose = () => {
|
transportToClient.onclose = () => {
|
||||||
@@ -26,7 +32,7 @@ export default function mcpProxy({
|
|||||||
}
|
}
|
||||||
|
|
||||||
transportToClientClosed = true;
|
transportToClientClosed = true;
|
||||||
transportToServer.close().catch(onerror);
|
transportToServer.close().catch(onServerError);
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToServer.onclose = () => {
|
transportToServer.onclose = () => {
|
||||||
@@ -34,10 +40,9 @@ export default function mcpProxy({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transportToServerClosed = true;
|
transportToServerClosed = true;
|
||||||
|
transportToClient.close().catch(onClientError);
|
||||||
transportToClient.close().catch(onerror);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
transportToClient.onerror = onerror;
|
transportToClient.onerror = onClientError;
|
||||||
transportToServer.onerror = onerror;
|
transportToServer.onerror = onServerError;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user