feat: Add basic response pane for MCP (#9131)

* New List page
* Add a common utility function
* Add response pane ui
* Support events and logs
This commit is contained in:
Kent Wang
2025-09-11 16:19:16 +08:00
parent a64a3c0ce4
commit e7bd06a2f9
21 changed files with 1043 additions and 243 deletions

86
package-lock.json generated
View File

@@ -4153,6 +4153,7 @@
"version": "1.17.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz",
"integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -4176,6 +4177,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -4189,6 +4191,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -4205,6 +4208,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -4225,6 +4229,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -4237,6 +4242,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -4246,6 +4252,7 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -4288,6 +4295,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -4305,6 +4313,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4314,12 +4323,14 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4329,6 +4340,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4341,6 +4353,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -4353,6 +4366,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4362,6 +4376,7 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -4377,6 +4392,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -4392,6 +4408,7 @@
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -4408,6 +4425,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
@@ -4430,6 +4448,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -4445,6 +4464,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -11211,6 +11231,7 @@
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
@@ -11711,6 +11732,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"dev": true,
"license": "MIT"
},
"node_modules/array-includes": {
@@ -12145,6 +12167,7 @@
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -12169,6 +12192,7 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -12178,6 +12202,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -12187,6 +12212,7 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -12199,6 +12225,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"license": "MIT"
},
"node_modules/boolbase": {
@@ -12468,6 +12495,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -13457,6 +13485,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -13473,6 +13502,7 @@
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -13496,6 +13526,7 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cookies": {
@@ -13569,6 +13600,7 @@
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -14238,6 +14270,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8",
@@ -14586,6 +14619,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true,
"license": "MIT"
},
"node_modules/ejs": {
@@ -14974,6 +15008,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -15580,6 +15615,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
@@ -16067,6 +16103,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -16111,6 +16148,7 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
@@ -16123,6 +16161,7 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -16169,6 +16208,7 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -16225,6 +16265,7 @@
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
@@ -16240,6 +16281,7 @@
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -16249,6 +16291,7 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -16258,6 +16301,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -16267,6 +16311,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"license": "MIT"
},
"node_modules/ext": {
@@ -16557,6 +16602,7 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
@@ -16575,6 +16621,7 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -16584,6 +16631,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"license": "MIT"
},
"node_modules/find-up": {
@@ -16750,6 +16798,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -16780,6 +16829,7 @@
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -17789,6 +17839,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
@@ -17805,6 +17856,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -18263,6 +18315,7 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -20384,6 +20437,7 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -20413,6 +20467,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -20432,6 +20487,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -21032,6 +21088,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -22195,6 +22252,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
@@ -22491,6 +22549,7 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -22648,6 +22707,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
@@ -22795,6 +22855,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
@@ -23383,6 +23444,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -23449,6 +23511,7 @@
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
@@ -23522,6 +23585,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -23531,6 +23595,7 @@
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -23546,6 +23611,7 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -24187,6 +24253,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -24203,6 +24270,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -24212,12 +24280,14 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true,
"license": "MIT"
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -24442,6 +24512,7 @@
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
@@ -24466,6 +24537,7 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -24475,12 +24547,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"license": "MIT"
},
"node_modules/send/node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -24490,6 +24564,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -24499,6 +24574,7 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
@@ -24551,6 +24627,7 @@
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
@@ -25317,6 +25394,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -26576,6 +26654,7 @@
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
@@ -26824,6 +26903,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -26972,6 +27052,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
@@ -27076,6 +27157,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -28470,6 +28552,7 @@
"version": "3.25.75",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz",
"integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -28479,6 +28562,7 @@
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
@@ -28496,7 +28580,6 @@
"@getinsomnia/node-libcurl": "3.0.0",
"@grpc/grpc-js": "^1.13.3",
"@grpc/proto-loader": "^0.7.13",
"@modelcontextprotocol/sdk": "^1.17.5",
"@seald-io/nedb": "^4.1.1",
"@segment/analytics-node": "2.2.1",
"@stoplight/spectral-core": "^1.20.0",
@@ -28559,6 +28642,7 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@getinsomnia/api-client": "0.0.10",
"@getinsomnia/srp-js": "1.0.0-alpha.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@react-router/dev": "^7.7.0",
"@react-router/fs-routes": "^7.7.0",
"@react-router/node": "^7.7.0",

View File

@@ -0,0 +1,108 @@
import {
CallToolRequestSchema,
CallToolResultSchema,
GetPromptRequestSchema,
GetPromptResultSchema,
InitializeRequestSchema,
InitializeResultSchema,
type JSONRPCMessage,
ListPromptsRequestSchema,
ListPromptsResultSchema,
ListResourcesRequestSchema,
ListResourcesResultSchema,
ListResourceTemplatesRequestSchema,
ListResourceTemplatesResultSchema,
ListToolsRequestSchema,
ListToolsResultSchema,
type Prompt,
ReadResourceRequestSchema,
ReadResourceResultSchema,
type Resource,
type ResourceTemplate,
type ServerCapabilities,
type Tool,
} from '@modelcontextprotocol/sdk/types.js';
// method constants
export const METHOD_INITIALIZE = InitializeRequestSchema.shape.method.value;
export const METHOD_LIST_TOOLS = ListToolsRequestSchema.shape.method.value;
export const METHOD_LIST_RESOURCES = ListResourcesRequestSchema.shape.method.value;
export const METHOD_LIST_RESOURCE_TEMPLATES = ListResourceTemplatesRequestSchema.shape.method.value;
export const METHOD_LIST_PROMPTS = ListPromptsRequestSchema.shape.method.value;
export const METHOD_CALL_TOOL = CallToolRequestSchema.shape.method.value;
export const METHOD_READ_RESOURCE = ReadResourceRequestSchema.shape.method.value;
export const METHOD_GET_PROMPT = GetPromptRequestSchema.shape.method.value;
const METHOD_UNKNOWN = 'unknown';
export const MCP_JSONRPC_METHODS = [
METHOD_INITIALIZE,
METHOD_LIST_TOOLS,
METHOD_LIST_RESOURCES,
METHOD_LIST_RESOURCE_TEMPLATES,
METHOD_LIST_PROMPTS,
METHOD_CALL_TOOL,
METHOD_READ_RESOURCE,
METHOD_GET_PROMPT,
];
export type JSONRPCMessageMethods =
| typeof METHOD_INITIALIZE
| typeof METHOD_LIST_TOOLS
| typeof METHOD_LIST_RESOURCES
| typeof METHOD_LIST_RESOURCE_TEMPLATES
| typeof METHOD_LIST_PROMPTS
| typeof METHOD_CALL_TOOL
| typeof METHOD_READ_RESOURCE
| typeof METHOD_GET_PROMPT;
export interface McpServerData {
serverCapabilities: ServerCapabilities;
primitives: {
tools: Tool[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
prompts: Prompt[];
};
}
export const getMcpMethodFromMessage = (message: JSONRPCMessage): JSONRPCMessageMethods | typeof METHOD_UNKNOWN => {
let method: JSONRPCMessageMethods | typeof METHOD_UNKNOWN = METHOD_UNKNOWN;
if ('result' in message) {
const messageResult = message.result;
if (InitializeResultSchema.safeParse(messageResult).success) {
method = METHOD_INITIALIZE;
} else if (ListToolsResultSchema.safeParse(messageResult).success) {
method = METHOD_LIST_TOOLS;
} else if (ListResourcesResultSchema.safeParse(messageResult).success) {
method = METHOD_LIST_RESOURCES;
} else if (ListResourceTemplatesResultSchema.safeParse(messageResult).success) {
method = METHOD_LIST_RESOURCE_TEMPLATES;
} else if (ListPromptsResultSchema.safeParse(messageResult).success) {
method = METHOD_LIST_PROMPTS;
} else if (CallToolResultSchema.safeParse(messageResult).success) {
method = METHOD_CALL_TOOL;
} else if (ReadResourceResultSchema.safeParse(messageResult).success) {
method = METHOD_READ_RESOURCE;
} else if (GetPromptResultSchema.safeParse(messageResult).success) {
method = METHOD_GET_PROMPT;
}
}
return method;
};
export const getDefaultServerCapabilities = () => {
return {
tools: {
enabled: false,
listChanged: false,
},
resources: {
enabled: false,
listChanged: false,
subscribe: true,
},
prompts: {
enabled: false,
listChanged: false,
},
};
};

View File

@@ -52,7 +52,7 @@ const socketIO: SocketIOBridgeAPI = {
const mcp: McpBridgeAPI = {
connect: options => ipcRenderer.invoke('mcp.connect', options),
close: options => ipcRenderer.send('mcp.close', options),
close: options => ipcRenderer.invoke('mcp.close', options),
closeAll: () => ipcRenderer.send('mcp.closeAll'),
primitive: {
listTools: options => ipcRenderer.invoke('mcp.primitive.listTools', options),
@@ -70,7 +70,6 @@ const mcp: McpBridgeAPI = {
event: {
findMany: options => ipcRenderer.invoke('mcp.event.findMany', options),
},
getServerData: options => ipcRenderer.invoke('mcp.getServerData', options),
};
const grpc: gRPCBridgeAPI = {

View File

@@ -105,7 +105,8 @@ export type HandleChannels =
| 'mcp.primitive.readResource'
| 'mcp.primitive.subscribeResource'
| 'mcp.readyState'
| 'mcp.getServerData';
| 'mcp.event.findMany'
| 'mcp.close';
export const ipcMainHandle = (
channel: HandleChannels,
@@ -148,7 +149,6 @@ export type MainOnChannels =
| 'updateLatestStepName'
| 'webSocket.close'
| 'webSocket.closeAll'
| 'mcp.close'
| 'mcp.closeAll'
| 'mcp.sendMCPRequest'
| 'writeText';

View File

@@ -1,30 +1,42 @@
import fs from 'node:fs';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type {
ClientRequest,
Prompt,
Resource,
ResourceTemplate,
ServerCapabilities,
Tool,
import {
type ClientRequest,
isInitializeRequest,
type JSONRPCMessage,
type JSONRPCResponse,
} from '@modelcontextprotocol/sdk/types.js';
import electron, { BrowserWindow } from 'electron';
import { v4 as uuidV4 } from 'uuid';
import type { z } from 'zod';
import { getAppVersion, getProductName } from '~/common/constants';
import { getMcpMethodFromMessage } from '~/common/mcp-utils';
import { generateId } from '~/common/misc';
import * as models from '~/models';
import type { TransportType } from '~/models/mcp-request';
import type { McpResponse } from '~/models/mcp-response';
import type { RequestAuthentication, RequestHeader } from '~/models/request';
import { getBasicAuthHeader } from '~/network/basic-auth/get-header';
import { getBearerAuthHeader } from '~/network/bearer-auth/get-header';
import { invariant } from '~/utils/invariant';
import { ipcMainHandle, ipcMainOn } from '../ipc/electron';
// Refer the SDK: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/shared/protocol.ts#L504
// The Client type has missing transport property
type McpClient = Client & { transport: StreamableHTTPClientTransport };
// Mcp connection and request options
interface CommonMcpOptions {
requestId: string;
}
export interface OpenMcpClientConnectionOptions extends CommonMcpOptions {
url: string;
requestId: string;
workspaceId: string;
transportType: TransportType;
headers: RequestHeader[];
authentication: RequestAuthentication;
@@ -39,21 +51,56 @@ interface CallToolOptions extends CommonMcpOptions {
name: string;
parameters: Record<string, any>;
}
export interface McpServerData {
serverCapabilities: ServerCapabilities;
primitives: {
tools: Tool[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
prompts: Prompt[];
};
interface McpCloseEvent {
_id: string;
requestId: string;
type: 'close';
timestamp: number;
reason: string;
}
export interface McpMessageEvent {
_id: string;
requestId: string;
type: 'message';
direction: 'INCOMING';
timestamp: number;
data: JSONRPCResponse;
method: string;
}
interface McpErrorEvent {
_id: string;
requestId: string;
timestamp: number;
type: 'error';
message: string;
error: any;
}
interface McpRequestEvent {
_id: string;
requestId: string;
type: 'message';
timestamp: number;
direction: 'OUTGOING';
method: string;
data: any;
}
export type McpEvent = McpMessageEvent | McpRequestEvent | McpCloseEvent | McpErrorEvent;
interface ResponseEventOptions {
responseId: string;
requestId: string;
environmentId: string | null;
timelinePath: string;
eventLogPath: string;
}
const mcpConnections = new Map<string, Client>();
// In-memory store of mcp server capabilities, tools/resources/resource templates/prompts list data for each mcp request
const mcpServerDataStore = new Map<string, McpServerData>();
const mcpConnections = new Map<string, McpClient>();
const eventLogFileStreams = new Map<string, fs.WriteStream>();
const timelineFileStreams = new Map<string, fs.WriteStream>();
const mcpStateChannelBuilder = (requestId: string) => `mcp.${requestId}.readyState`;
const protocol = 'mcp';
const getMcpStateChannel = (requestId: string) => `${protocol}.${requestId}.readyState`;
const mcpEventIdGenerator = () => `mcp-${uuidV4()}`;
const _getMcpClient = (id: string) => {
const mcpClient = mcpConnections.get(id);
if (!mcpClient) {
@@ -68,13 +115,185 @@ const _notifyMcpClientStateChange = (channel: string, isConnected: boolean) => {
}
};
const _clearMcpMaps = (requestId: string) => {
const _clearMcpMaps = (requestId: string, timelineMessage: string, event?: McpEvent) => {
if (event) {
eventLogFileStreams.get(requestId)?.write(JSON.stringify(event) + '\n');
}
eventLogFileStreams.get(requestId)?.end();
eventLogFileStreams.delete(requestId);
timelineFileStreams
.get(requestId)
?.write(JSON.stringify({ value: timelineMessage, name: 'Text', timestamp: Date.now() }) + '\n');
timelineFileStreams.get(requestId)?.end();
timelineFileStreams.delete(requestId);
mcpConnections.delete(requestId);
mcpServerDataStore.delete(requestId);
};
const _handleCloseMcpConnection = (requestId: string, error?: Error) => {
if (error) {
const closeEvent: McpErrorEvent = {
_id: mcpEventIdGenerator(),
requestId,
type: 'error',
timestamp: Date.now(),
error,
message: error.message || 'Unknown error',
};
// clear in-memory store
_clearMcpMaps(requestId, 'Closed MCP connection', closeEvent);
} else {
const closeEvent: McpCloseEvent = {
_id: mcpEventIdGenerator(),
requestId,
type: 'close',
timestamp: Date.now(),
reason: 'Mcp connection closed',
};
// clear in-memory store
_clearMcpMaps(requestId, 'Closed MCP connection', closeEvent);
}
const mcpStateChannel = getMcpStateChannel(requestId);
// notify renderer process about state change
_notifyMcpClientStateChange(mcpStateChannel, false);
};
const _handleMcpConnectionError = (requestId: string, error: Error) => {
const messageEvent: McpErrorEvent = {
_id: mcpEventIdGenerator(),
requestId,
type: 'error',
message: error.message || 'Unknown error',
error,
timestamp: Date.now(),
};
eventLogFileStreams.get(requestId)?.write(JSON.stringify(messageEvent) + '\n');
console.error(`MCP connection error for requestId: ${requestId}`, error);
// _handleCloseMcpConnection(requestId);
};
const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
const method = getMcpMethodFromMessage(message);
const messageEvent: McpMessageEvent = {
_id: mcpEventIdGenerator(),
requestId,
type: 'message',
method,
data: message as JSONRPCResponse,
direction: 'INCOMING',
timestamp: Date.now(),
};
eventLogFileStreams.get(requestId)?.write(JSON.stringify(messageEvent) + '\n');
};
const createErrorResponse = async ({
requestId,
responseId,
environmentId,
timelinePath,
message,
}: ResponseEventOptions & { message: string }) => {
const settings = await models.settings.get();
const responsePatch = {
_id: responseId,
parentId: requestId,
environmentId: environmentId,
timelinePath,
statusMessage: 'Error',
error: message,
};
const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
};
const getInitialTimeline = (url: string) => {
return [
{ value: `Preparing request to ${url}`, name: 'Text', timestamp: Date.now() },
{ value: `Current time is ${new Date().toISOString()}`, name: 'Text', timestamp: Date.now() },
];
};
const parseResponseAndBuildTimeline = (requestHeaderLogs: string, response: Response) => {
const statusMessage = response.statusText || '';
const statusCode = response.status || 0;
const responseHeaders: { name: string; value: string }[] = [...response.headers.entries()].map(([name, value]) => ({
name,
value,
}));
const headersIn = responseHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n');
const timeline = [
{ value: requestHeaderLogs, name: 'HeaderOut', timestamp: Date.now() },
{ value: `${statusCode} ${statusMessage}`, name: 'HeaderIn', timestamp: Date.now() },
{ value: headersIn, name: 'HeaderIn', timestamp: Date.now() },
];
return { timeline, responseHeaders, statusCode, statusMessage };
};
// A wrapper fetch to log request and response details
const fetchWithLogging = async (
url: string | URL,
init: RequestInit,
{ requestId, responseId, environmentId, timelinePath, eventLogPath }: ResponseEventOptions,
) => {
const { method = 'GET' } = init;
const reqHeader = new Headers(init?.headers || {});
const isJsonRequest = reqHeader.get('content-type')?.toLowerCase().includes('application/json');
const requestBody = isJsonRequest ? JSON.parse(init.body?.toString() || '{}') : init.body?.toString() || '';
const isMcpInitializeRequest = isJsonRequest && isInitializeRequest(requestBody);
if (isMcpInitializeRequest) {
// Add initial timeline
const initialTimelines = getInitialTimeline(url.toString());
initialTimelines.map(t => timelineFileStreams.get(requestId)?.write(JSON.stringify(t) + '\n'));
}
const requestHeaders: { name: string; value: string }[] = [...reqHeader.entries()].map(([name, value]) => ({
name,
value,
}));
const requestMethodLine = `${method.toUpperCase()} ${url} ${isJsonRequest && requestBody?.method ? `\nJSON-RPC Method: ${requestBody.method}` : ''}`;
const headersOut = requestHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n');
const start = performance.now();
const response = await fetch(url, init);
const { timeline, responseHeaders, statusCode, statusMessage } = parseResponseAndBuildTimeline(
`${requestMethodLine}\n${headersOut}`,
response,
);
timeline.map(t => timelineFileStreams.get(requestId)?.write(JSON.stringify(t) + '\n'));
if (isMcpInitializeRequest) {
// Create response model only for initialize response
const responsePatch: Partial<McpResponse> = {
_id: responseId,
parentId: requestId,
environmentId,
headers: responseHeaders,
url: url.toString(),
statusCode,
statusMessage,
elapsedTime: performance.now() - start,
timelinePath,
eventLogPath,
};
const settings = await models.settings.get();
const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
}
if (requestBody) {
// Add request event
const requestEvent: McpRequestEvent = {
_id: mcpEventIdGenerator(),
method: requestBody.method,
requestId,
type: 'message',
direction: 'OUTGOING',
timestamp: Date.now(),
data: requestBody,
};
eventLogFileStreams.get(requestId)?.write(JSON.stringify(requestEvent) + '\n');
}
return response;
};
const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) => {
const { transportType, url, requestId } = options;
const { transportType, url, requestId, workspaceId } = options;
if (!url) {
throw new Error('MCP server url is required');
}
@@ -103,20 +322,47 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
.filter(({ name, disabled }) => Boolean(name) && !disabled)
.reduce(reduceArrayToLowerCaseKeyedDictionary, {});
// create response model and file streams
const responseId = generateId('res');
const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses');
const eventLogPath = path.join(responsesDir, uuidV4() + '.response');
eventLogFileStreams.set(requestId, fs.createWriteStream(eventLogPath));
const timelinePath = path.join(responsesDir, responseId + '.timeline');
timelineFileStreams.set(requestId, fs.createWriteStream(timelinePath));
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspaceId);
// fallback to base environment
const activeEnvironmentId = workspaceMeta.activeEnvironmentId;
const activeEnvironment = activeEnvironmentId && (await models.environment.getById(activeEnvironmentId));
const environment = activeEnvironment || (await models.environment.getOrCreateForParentId(workspaceId));
invariant(environment, 'failed to find environment ' + activeEnvironmentId);
const responseEnvironmentId = environment ? environment._id : null;
// create connection
const mcpClient = new Client({
name: getProductName(),
version: getAppVersion(),
});
const mcpStateChannel = mcpStateChannelBuilder(requestId);
mcpClient.onclose = () => _handleCloseMcpConnection(requestId);
mcpClient.onerror = _error => _handleMcpConnectionError(requestId, _error);
const mcpStateChannel = getMcpStateChannel(requestId);
let transport: StreamableHTTPClientTransport;
switch (transportType) {
case 'streamable-http': {
try {
const mcpServerUrl = new URL(url);
const transport = new StreamableHTTPClientTransport(mcpServerUrl, {
transport = new StreamableHTTPClientTransport(mcpServerUrl, {
requestInit: {
headers: lowerCasedEnabledHeaders,
},
fetch: (url, init) =>
fetchWithLogging(url, init || {}, {
requestId,
responseId,
environmentId: responseEnvironmentId,
timelinePath,
eventLogPath,
}),
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
@@ -124,73 +370,56 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
maxRetries: 2,
},
});
transport.onmessage = message => _handleMcpMessage(message, requestId);
await mcpClient.connect(transport);
mcpClient.onclose = () => {
// terminate the session when client is closed
transport.terminateSession();
// clear in-memory store
_clearMcpMaps(requestId);
// notify renderer process about state change
_notifyMcpClientStateChange(mcpStateChannel, false);
};
mcpClient.onerror = _error => {
// TODO support error
};
break;
} catch (error) {
throw new Error(`Failed to create Streamable HTTP transport: ${error}`);
// Log error when connection fails with exception
createErrorResponse({
requestId,
responseId,
environmentId: responseEnvironmentId,
timelinePath,
eventLogPath,
message: error.message || 'Something went wrong',
});
console.error(`Failed to create Streamable HTTP transport: ${error}`);
return;
}
break;
}
default: {
throw new Error(`Unsupported transport type: ${transportType}`);
}
}
mcpConnections.set(requestId, mcpClient);
mcpConnections.set(requestId, mcpClient as McpClient);
const serverCapabilities = mcpClient.getServerCapabilities();
let tools: Tool[] = [];
let resources: Resource[] = [];
let resourceTemplates: ResourceTemplate[] = [];
let prompts: Prompt[] = [];
const primitivePromises: Promise<any>[] = [];
// get server primitives if supported
if (serverCapabilities?.tools) {
primitivePromises.push(mcpClient.listTools().then(response => (tools = response.tools)));
primitivePromises.push(mcpClient.listTools());
}
if (serverCapabilities?.resources) {
primitivePromises.push(mcpClient.listResources().then(response => (resources = response.resources)));
primitivePromises.push(
mcpClient.listResourceTemplates().then(response => (resourceTemplates = response.resourceTemplates)),
);
primitivePromises.push(mcpClient.listResources());
primitivePromises.push(mcpClient.listResourceTemplates());
}
if (serverCapabilities?.prompts) {
primitivePromises.push(mcpClient.listPrompts().then(response => (prompts = response.prompts)));
primitivePromises.push(mcpClient.listPrompts());
}
try {
await Promise.all(primitivePromises);
} catch (error) {
console.warn('Failed to fetch one or more primitive types from MCP server', error);
}
const serverData = {
serverCapabilities: serverCapabilities,
primitives: {
tools,
resources,
resourceTemplates,
prompts,
},
};
mcpServerDataStore.set(requestId, serverData as McpServerData);
// notify connection ready after capabilities and primitives are fetched
_notifyMcpClientStateChange(mcpStateChannel, true);
return serverData;
};
const closeMcpConnection = (options: CommonMcpOptions) => {
const closeMcpConnection = async (options: CommonMcpOptions) => {
const { requestId } = options;
const mcpClient = _getMcpClient(requestId);
if (mcpClient) {
await mcpClient.transport.terminateSession();
mcpClient.close();
}
};
@@ -201,10 +430,22 @@ const closeAllMcpConnections = () => {
}
};
const getServerData = async (options: CommonMcpOptions) => mcpServerDataStore.get(options.requestId);
const findMany = async (_options: { responseId: string }): Promise<any> => {
return [];
const findMany = async (options: { responseId: string }): Promise<McpEvent[]> => {
const response = await models.mcpResponse.getById(options.responseId);
if (!response || !response.eventLogPath) {
return [];
}
const body = await fs.promises.readFile(response.eventLogPath);
return (
body
.toString()
.split('\n')
.filter(e => e?.trim())
// Parse the message
.map(e => JSON.parse(e))
// Reverse the list of messages so that we get the latest message first
.reverse() || []
);
};
const listTools = async (options: CommonMcpOptions) => {
@@ -269,7 +510,6 @@ export interface McpBridgeAPI {
connect: typeof openMcpClientConnection;
close: typeof closeMcpConnection;
closeAll: typeof closeAllMcpConnections;
getServerData: typeof getServerData;
primitive: {
listTools: typeof listTools;
callTool: typeof callTool;
@@ -308,10 +548,10 @@ export const registerMcpHandlers = () => {
ipcMainHandle('mcp.primitive.subscribeResource', (_, options: Parameters<typeof subscribeResource>[0]) =>
subscribeResource(options),
);
ipcMainOn('mcp.close', (_, options: Parameters<typeof closeMcpConnection>[0]) => closeMcpConnection(options));
ipcMainHandle('mcp.close', (_, options: Parameters<typeof closeMcpConnection>[0]) => closeMcpConnection(options));
ipcMainOn('mcp.closeAll', closeAllMcpConnections);
ipcMainHandle('mcp.readyState', (_, options: Parameters<typeof getMcpReadyState>[0]) => getMcpReadyState(options));
ipcMainHandle('mcp.getServerData', (_, options: Parameters<typeof getMcpReadyState>[0]) => getServerData(options));
ipcMainHandle('mcp.event.findMany', (_, options: Parameters<typeof findMany>[0]) => findMany(options));
};
electron.app.on('window-all-closed', closeAllMcpConnections);

View File

@@ -14,6 +14,8 @@ export const canSync = false;
export interface BaseMcpResponse {
environmentId: string | null;
statusCode: number;
statusMessage: string;
url: string;
elapsedTime: number;
headers: ResponseHeader[];
@@ -37,6 +39,8 @@ export function init(): BaseMcpResponse {
timelinePath: '',
eventLogPath: '',
error: '',
statusCode: 0,
statusMessage: '',
requestVersionId: null,
environmentId: null,
};

View File

@@ -5,7 +5,7 @@ import { compressObject, decompressObject } from '../common/misc';
import * as requestOperations from '../models/helpers/request-operations';
import type { GrpcRequest } from './grpc-request';
import type { BaseModel } from './index';
import type { McpRequest } from './mcp-request';
import { isMcpRequest, type McpRequest } from './mcp-request';
import { isRequest, type Request } from './request';
import { isSocketIORequest, type SocketIORequest } from './socket-io-request';
import { isWebSocketRequest, type WebSocketRequest } from './websocket-request';
@@ -58,7 +58,7 @@ export function findByParentId(parentId: string) {
}
export async function create(request: Request | WebSocketRequest | GrpcRequest | SocketIORequest | McpRequest) {
if (!isRequest(request) && !isWebSocketRequest(request) && !isSocketIORequest(request)) {
if (!isRequest(request) && !isWebSocketRequest(request) && !isSocketIORequest(request) && !isMcpRequest(request)) {
throw new Error(`New ${type} was not given a valid ${request.type} instance`);
}
@@ -118,8 +118,8 @@ export async function restore(requestVersionId: string) {
return requestOperations.update(originalRequest, requestPatch);
}
function _diffRequests(
rOld: Request | WebSocketRequest | SocketIORequest | null,
rNew: Request | WebSocketRequest | SocketIORequest,
rOld: Request | WebSocketRequest | SocketIORequest | McpRequest | null,
rNew: Request | WebSocketRequest | SocketIORequest | McpRequest,
) {
if (!rOld) {
return true;

View File

@@ -2,6 +2,7 @@ import { href } from 'react-router';
import * as models from '~/models';
import * as requestOperations from '~/models/helpers/request-operations';
import { isMcpRequestId } from '~/models/mcp-request';
import { isSocketIORequestId } from '~/models/socket-io-request';
import { isWebSocketRequestId } from '~/models/websocket-request';
import { invariant } from '~/utils/invariant';
@@ -22,6 +23,8 @@ export async function clientAction({ params }: Route.ClientActionArgs) {
await models.webSocketResponse.removeForRequest(requestId, workspaceMeta.activeEnvironmentId);
} else if (isSocketIORequestId(requestId)) {
await models.socketIOResponse.removeForRequest(requestId, workspaceMeta.activeEnvironmentId);
} else if (isMcpRequestId(requestId)) {
await models.mcpResponse.removeForRequest(requestId, workspaceMeta.activeEnvironmentId);
} else {
await models.response.removeForRequest(requestId, workspaceMeta.activeEnvironmentId);
}

View File

@@ -2,8 +2,13 @@ import { href } from 'react-router';
import * as models from '~/models';
import * as requestOperations from '~/models/helpers/request-operations';
import { isMcpRequestId } from '~/models/mcp-request';
import type { McpResponse } from '~/models/mcp-response';
import type { Response } from '~/models/response';
import { isSocketIORequestId } from '~/models/socket-io-request';
import type { SocketIOResponse } from '~/models/socket-io-response';
import { isWebSocketRequestId } from '~/models/websocket-request';
import type { WebSocketResponse } from '~/models/websocket-response';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -20,36 +25,42 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
invariant(workspaceMeta, 'Active workspace meta not found');
const isWebSocketRequest = isWebSocketRequestId(requestId);
const isSocketIORequest = isSocketIORequestId(requestId);
const isMcpRequest = isMcpRequestId(requestId);
if (isWebSocketRequestId(requestId)) {
const res = await models.webSocketResponse.getById(responseId);
invariant(res, 'Response not found');
await models.webSocketResponse.remove(res);
const response = await models.webSocketResponse.getLatestForRequestId(requestId, workspaceMeta.activeEnvironmentId);
if (response?.requestVersionId) {
await models.requestVersion.restore(response.requestVersionId);
}
await models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: response?._id || null });
} else if (isSocketIORequestId(requestId)) {
const res = await models.socketIOResponse.getById(responseId);
invariant(res, 'Response not found');
await models.socketIOResponse.remove(res);
const response = await models.socketIOResponse.getLatestForRequestId(requestId, workspaceMeta.activeEnvironmentId);
if (response?.requestVersionId) {
await models.requestVersion.restore(response.requestVersionId);
}
await models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: response?._id || null });
let responseModel;
if (isWebSocketRequest) {
responseModel = models.webSocketResponse;
} else if (isSocketIORequest) {
responseModel = models.socketIOResponse;
} else if (isMcpRequest) {
responseModel = models.mcpResponse;
} else {
const res = await models.response.getById(responseId);
invariant(res, 'Response not found');
await models.response.remove(res);
const response = await models.response.getLatestForRequestId(requestId, workspaceMeta.activeEnvironmentId);
if (response?.requestVersionId) {
await models.requestVersion.restore(response.requestVersionId);
}
await models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: response?._id || null });
responseModel = models.response;
}
const res = await responseModel.getById(responseId);
invariant(res, 'Response not found');
// Type-safe remove operation based on the request type
if (isWebSocketRequest) {
await models.webSocketResponse.remove(res as WebSocketResponse);
} else if (isSocketIORequest) {
await models.socketIOResponse.remove(res as SocketIOResponse);
} else if (isMcpRequest) {
await models.mcpResponse.remove(res as McpResponse);
} else {
await models.response.remove(res as Response);
}
const response = await responseModel.getLatestForRequestId(requestId, workspaceMeta.activeEnvironmentId);
if (response?.requestVersionId) {
await models.requestVersion.restore(response.requestVersionId);
}
await models.requestMeta.updateOrCreateByParentId(requestId, {
activeResponseId: response?._id || null,
});
return null;
}

View File

@@ -31,6 +31,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
window.main.mcp.connect({
requestId,
workspaceId,
transportType: rendered.transportType || 'streamable-http',
url: rendered.url,
headers: rendered.headers,

View File

@@ -1,4 +1,3 @@
import type { Prompt, Resource, ResourceTemplate, Tool } from '@modelcontextprotocol/sdk/types.js';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -18,17 +17,35 @@ import { NavLink, redirect, useParams } from 'react-router';
import { useLocalStorage } from 'react-use';
import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants';
import type { McpServerData } from '~/main/network/mcp';
import {
getDefaultServerCapabilities,
type McpServerData,
METHOD_INITIALIZE,
METHOD_LIST_PROMPTS,
METHOD_LIST_RESOURCE_TEMPLATES,
METHOD_LIST_RESOURCES,
METHOD_LIST_TOOLS,
} from '~/common/mcp-utils';
import type { McpEvent, McpMessageEvent } from '~/main/network/mcp';
import * as models from '~/models';
import type { McpServerPrimitiveTypes } from '~/models/mcp-request';
import type { McpRequest, McpServerPrimitiveTypes } from '~/models/mcp-request';
import { useRootLoaderData } from '~/root';
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useMcpRequestLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
import { McpActionsDropdown } from '~/ui/components/dropdowns/mcp-actions-dropdown';
import { WorkspaceDropdown } from '~/ui/components/dropdowns/workspace-dropdown';
import { EnvironmentPicker } from '~/ui/components/environment-picker';
import { ErrorBoundary } from '~/ui/components/error-boundary';
import { Icon } from '~/ui/components/icon';
import { McpRequestPane } from '~/ui/components/mcp/mcp-request-pane';
import {
type PrimitiveSubItem,
type PrimitiveTypeItem,
type PromptItem,
type ResourceItem,
type ResourceTemplateItem,
type ToolItem,
} from '~/ui/components/mcp/types';
import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal';
import { OrganizationTabList } from '~/ui/components/tabs/tab-list';
import { McpRealtimeResponsePane } from '~/ui/components/websockets/realtime-response-pane';
@@ -38,20 +55,6 @@ import { invariant } from '~/utils/invariant';
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp';
interface CommonItemProps {
itemLevel: number;
hide: boolean;
}
type ToolItem = Tool & { type: 'tools' } & CommonItemProps;
type ResourceItem = Resource & { type: 'resources' } & CommonItemProps;
type ResourceTemplateItem = ResourceTemplate & { type: 'resources' } & CommonItemProps;
type PromptItem = Prompt & { type: 'prompts' } & CommonItemProps;
export type PrimitiveSubItemTypes = ToolItem | ResourceItem | ResourceTemplateItem | PromptItem;
interface PrimitiveTypeItem extends CommonItemProps {
type: McpServerPrimitiveTypes;
name: string;
}
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
if (!params.requestId) {
const { projectId, workspaceId, organizationId } = params;
@@ -78,7 +81,7 @@ const McpPage = () => {
projectId: string;
workspaceId: string;
};
const { activeRequest } = useMcpRequestLoaderData()!;
const { activeRequest, activeResponse } = useMcpRequestLoaderData()!;
const sidebarPanelRef = useRef<ImperativePanelGroupHandle>(null);
const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false);
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false);
@@ -87,10 +90,10 @@ const McpPage = () => {
const { settings } = useRootLoaderData()!;
const [mcpServerData, setMcpServerData] = useState<McpServerData | null>(null);
const [collapsedPrimitives, setCollapsedPrimitives] = useState<McpServerPrimitiveTypes[]>([]);
const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState<PrimitiveSubItemTypes | null>(null);
console.log('selectedPrimitiveItem', selectedPrimitiveItem);
const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState<PrimitiveSubItem | null>(null);
const getPrimitiveCollection = () => {
const collection: (PrimitiveTypeItem | PrimitiveSubItemTypes)[] = [];
const collection: (PrimitiveTypeItem | PrimitiveSubItem)[] = [];
if (mcpServerData) {
const { primitives } = mcpServerData;
const { tools, resources, resourceTemplates, prompts } = primitives;
@@ -140,21 +143,7 @@ const McpPage = () => {
};
const getServerCapabilities = () => {
const serverCapabilities = {
tools: {
enabled: true,
listChanged: false,
},
resources: {
enabled: true,
listChanged: false,
subscribe: true,
},
prompts: {
enabled: true,
listChanged: false,
},
};
const serverCapabilities = getDefaultServerCapabilities();
if (mcpServerData) {
const { tools, resources, prompts } = mcpServerData.serverCapabilities;
if (tools) {
@@ -183,10 +172,9 @@ const McpPage = () => {
serverCapabilities.resources.listChanged ||
serverCapabilities.prompts.listChanged;
// TODO Use these variables to enable notification
console.log('enableNotification', enableNotification);
console.log('allowSubscribeResources', allowSubscribeResources);
// TODO Use this for showing details
console.log('selectedPrimitiveItem', selectedPrimitiveItem);
console.log(`enableNotification`, enableNotification);
console.log(`allowSubscribeResources`, allowSubscribeResources);
const requestId = activeRequest._id;
const { activeEnvironment } = useWorkspaceLoaderData()!;
const readyState = useReadyState({ requestId, protocol: 'mcp' });
@@ -251,16 +239,42 @@ const McpPage = () => {
useEffect(() => {
const updateServerData = async () => {
const serverData = await window.main.mcp.getServerData({ requestId });
setMcpServerData(serverData!);
const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => {
const firstMatchEvent = mcpEvents.find(
event => 'method' in event && event.method === method,
) as McpMessageEvent;
if (firstMatchEvent) {
return firstMatchEvent.data.result;
}
return undefined;
};
const activeResponseId = activeResponse?._id;
if (activeResponseId) {
const allEvents = await window.main.mcp.event.findMany({ responseId: activeResponseId });
const serverCapabilities =
findFirstMatchEventData(allEvents, METHOD_INITIALIZE)?.capabilities || getDefaultServerCapabilities();
const tools = findFirstMatchEventData(allEvents, METHOD_LIST_TOOLS)?.tools || [];
const resources = findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCES)?.resources || [];
const resourceTemplates =
findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCE_TEMPLATES)?.resourceTemplates || [];
const prompts = findFirstMatchEventData(allEvents, METHOD_LIST_PROMPTS)?.prompts || [];
const mcpServerData = {
serverCapabilities: serverCapabilities,
primitives: {
tools,
resources,
resourceTemplates,
prompts,
},
} as McpServerData;
setMcpServerData(mcpServerData);
}
};
if (readyState) {
// Get MCP server data when connection is ready
updateServerData();
} else {
setMcpServerData(null);
}
}, [readyState, requestId]);
}, [readyState, activeResponse?._id]);
return (
<PanelGroup
@@ -372,58 +386,22 @@ const McpPage = () => {
// Click a specified primitive
const [type, name] = id.split('_');
const item = visibleCollection.find(i => i.itemLevel === 1 && i.type === type && i.name === name);
setSelectedPrimitiveItem(item as PrimitiveSubItemTypes);
setSelectedPrimitiveItem(item as PrimitiveSubItem);
}
}}
>
{virtualItem => {
const item = visibleCollection[virtualItem.index];
const label = 'title' in item ? item.title : item.name;
const uniqueId = item.itemLevel === 0 ? `root_${item.type}` : `${item.type}_${item.name}`;
const itemLevel = item.itemLevel;
return (
<GridListItem
id={uniqueId}
className={`group absolute left-0 top-0 w-full select-none outline-none ${item.itemLevel === 0 ? 'data-[drop-target]:bg-[--hl-md]' : 'border-solid data-[drop-target]:border-b data-[drop-target]:border-[--color-surprise]'}`}
textValue={label}
data-testid={`test-${uniqueId}`}
<CollectionGridListItem
activeRequest={activeRequest}
item={item}
collapsedPrimitives={collapsedPrimitives}
style={{
height: `${virtualItem.size}`,
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div
className="relative flex h-[--line-height-xs] w-full select-none items-center gap-2 overflow-hidden pl-4 pr-2 text-[--hl] outline-none transition-colors group-hover:bg-[--hl-xs] group-focus:bg-[--hl-sm] data-[selected=true]:text-[--color-font]"
style={{
paddingLeft: `${itemLevel}em`,
}}
>
<div className="relative flex h-[--line-height-xs] w-full select-none items-center gap-2 overflow-hidden px-4 text-[--hl] outline-none transition-colors">
{itemLevel === 0 && (
<Icon
className="w-4 flex-shrink-0"
icon={collapsedPrimitives.includes(item.type) ? 'caret-right' : 'caret-down'}
/>
)}
{item.type === 'tools' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-success-rgb),0.5)] text-[0.65rem] text-[--color-font-success]">
Tool
</span>
)}
{item.type === 'resources' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-surprise-rgb),0.5)] text-[0.65rem] text-[--color-font-surprise]">
Res
</span>
)}
{item.type === 'prompts' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-info-rgb),0.5)] text-[0.65rem] text-[--color-font-info]">
Prompt
</span>
)}
{label}
</div>
</div>
</GridListItem>
/>
);
}}
</GridList>
@@ -438,7 +416,9 @@ const McpPage = () => {
<PanelGroup autoSaveId="insomnia-panels" id="insomnia-panels" direction={direction}>
<Panel id="mcp-request-pane" order={1} minSize={10} className="pane-one theme--pane">
<McpRequestPane
selectedPrimitiveItem={selectedPrimitiveItem}
selectedPrimitiveItem={
selectedPrimitiveItem?.itemLevel === 1 ? (selectedPrimitiveItem as PrimitiveSubItem) : null
}
environment={activeEnvironment}
readyState={readyState}
/>
@@ -454,4 +434,76 @@ const McpPage = () => {
);
};
const CollectionGridListItem = ({
activeRequest,
style,
item,
collapsedPrimitives,
}: {
activeRequest: McpRequest;
item: PrimitiveTypeItem | PrimitiveSubItem;
style: React.CSSProperties;
collapsedPrimitives: McpServerPrimitiveTypes[];
}) => {
const label = 'title' in item ? item.title : item.name;
const uniqueId = item.itemLevel === 0 ? `root_${item.type}` : `${item.type}_${item.name}`;
const itemLevel = item.itemLevel;
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
return (
<GridListItem
id={uniqueId}
className={`group absolute left-0 top-0 w-full select-none outline-none ${item.itemLevel === 0 ? 'data-[drop-target]:bg-[--hl-md]' : 'border-solid data-[drop-target]:border-b data-[drop-target]:border-[--color-surprise]'}`}
textValue={label}
data-testid={`test-${uniqueId}`}
style={style}
ref={triggerRef}
>
<div
onContextMenu={e => {
e.preventDefault();
setIsContextMenuOpen(true);
}}
className="relative flex h-[--line-height-xs] w-full select-none items-center gap-2 overflow-hidden pl-4 pr-2 text-[--hl] outline-none transition-colors group-hover:bg-[--hl-xs] group-focus:bg-[--hl-sm] data-[selected=true]:text-[--color-font]"
style={{
paddingLeft: `${itemLevel}em`,
}}
>
<div className="relative flex h-[--line-height-xs] w-full select-none items-center gap-2 overflow-hidden px-4 text-[--hl] outline-none transition-colors">
{itemLevel === 0 && (
<Icon
className="w-4 flex-shrink-0"
icon={collapsedPrimitives.includes(item.type) ? 'caret-right' : 'caret-down'}
/>
)}
{item.type === 'tools' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-success-rgb),0.5)] text-[0.65rem] text-[--color-font-success]">
Tool
</span>
)}
{item.type === 'resources' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-surprise-rgb),0.5)] text-[0.65rem] text-[--color-font-surprise]">
Res
</span>
)}
{item.type === 'prompts' && item.itemLevel === 1 && (
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm border border-solid border-[--hl-sm] bg-[rgba(var(--color-info-rgb),0.5)] text-[0.65rem] text-[--color-font-info]">
Prompt
</span>
)}
{label}
</div>
<McpActionsDropdown
item={item}
request={activeRequest}
isOpen={isContextMenuOpen}
onOpenChange={setIsContextMenuOpen}
triggerRef={triggerRef}
/>
</div>
</GridListItem>
);
};
export default McpPage;

View File

@@ -0,0 +1,121 @@
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import React from 'react';
import { Button, Collection, Header, Menu, MenuItem, MenuSection, MenuTrigger, Popover } from 'react-aria-components';
import type { PlatformKeyCombinations } from '../../../common/settings';
import type { McpRequest } from '../../../models/mcp-request';
import { Icon } from '../icon';
import type { PrimitiveSubItem, PrimitiveTypeItem } from '../mcp/types';
interface Props {
item: PrimitiveTypeItem | PrimitiveSubItem;
request: McpRequest;
triggerRef: React.RefObject<HTMLDivElement>;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export const McpActionsDropdown = ({ item, request, isOpen, onOpenChange, triggerRef }: Props) => {
const { itemLevel, type } = item;
if (itemLevel !== 0) {
// Only show for capability type item
return null;
}
const requestId = request._id;
const handleRefreshPrimitive = () => {
if (type === 'tools') {
window.main.mcp.primitive.listTools({ requestId });
} else if (type === 'prompts') {
window.main.mcp.primitive.listPrompts({ requestId });
} else if (type === 'resources') {
window.main.mcp.primitive.listResources({ requestId });
}
};
const mcpPrimitiveActionList: {
name: string;
id: string;
icon: IconName;
items: {
id: string;
name: string;
icon: IconName;
hint?: PlatformKeyCombinations;
action: () => void;
}[];
}[] = [
{
name: 'Actions',
id: 'actions',
icon: 'cog',
items: [
{
id: 'Refresh',
name: 'Refresh',
action: handleRefreshPrimitive,
icon: 'refresh',
},
],
},
];
return (
<MenuTrigger
isOpen={isOpen}
onOpenChange={isOpen => {
onOpenChange(isOpen);
}}
>
<Button
data-testid={`Dropdown-${item.type}`}
aria-label="Mcp Actions"
className="hidden aspect-square h-6 items-center justify-center rounded-sm text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] group-hover:flex group-focus:flex aria-pressed:bg-[--hl-sm]"
>
<Icon icon="caret-down" />
</Button>
<Popover
className="flex min-w-max flex-col overflow-y-hidden"
triggerRef={triggerRef}
placement="bottom end"
offset={5}
>
<Menu
aria-label="Mcp Actions Menu"
selectionMode="single"
onAction={key =>
mcpPrimitiveActionList
.find(i => i.items.find(a => a.id === key))
?.items.find(a => a.id === key)
?.action()
}
items={mcpPrimitiveActionList}
className="min-w-max select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] py-2 text-sm shadow-lg focus:outline-none"
>
{section => (
<MenuSection className="flex flex-1 flex-col">
<Header className="flex items-center gap-2 py-1 pl-2 text-xs uppercase text-[--hl]">
<Icon icon={section.icon} /> <span>{section.name}</span>
</Header>
<Collection items={section.items}>
{item => (
<MenuItem
key={item.id}
id={item.id}
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
</MenuItem>
)}
</Collection>
</MenuSection>
)}
</Menu>
</Popover>
</MenuTrigger>
);
};

View File

@@ -8,17 +8,14 @@ import { useRequestResponseDeleteAllActionFetcher } from '~/routes/organization.
import { decompressObject } from '../../../common/misc';
import * as models from '../../../models/index';
import { isMcpResponse, type McpResponse } from '../../../models/mcp-response';
import { isRequest, type Request } from '../../../models/request';
import { type RequestVersion } from '../../../models/request-version';
import type { Response } from '../../../models/response';
import { isSocketIOResponse, type SocketIOResponse } from '../../../models/socket-io-response';
import type { WebSocketRequest } from '../../../models/websocket-request';
import { isWebSocketResponse, type WebSocketResponse } from '../../../models/websocket-response';
import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import {
type RequestLoaderData,
useRequestLoaderData,
type WebSocketRequestLoaderData,
} from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import { useRequestMetaPatcher } from '../../hooks/use-request';
import { Dropdown, type DropdownHandle, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
import { useDocBodyKeyboardShortcuts } from '../keydown-binder';
@@ -28,10 +25,16 @@ import { TimeTag } from '../tags/time-tag';
import { URLTag } from '../tags/url-tag';
import { TimeFromNow } from '../time-from-now';
type ResponseType = Response | WebSocketResponse | SocketIOResponse | McpResponse;
export const ResponseHistoryDropdown = ({
activeResponse,
responses,
requestVersions,
}: {
activeResponse: Response | WebSocketResponse | SocketIOResponse;
activeResponse: ResponseType;
responses: ResponseType[];
requestVersions: RequestVersion[];
}) => {
const { organizationId, projectId, workspaceId, requestId } = useParams() as {
organizationId: string;
@@ -42,9 +45,9 @@ export const ResponseHistoryDropdown = ({
const dropdownRef = useRef<DropdownHandle>(null);
const patchRequestMeta = useRequestMetaPatcher();
const { activeEnvironment } = useWorkspaceLoaderData()!;
const { responses, requestVersions } = useRequestLoaderData() as RequestLoaderData | WebSocketRequestLoaderData;
// const { responses, requestVersions } = useRequestLoaderData() as RequestLoaderData | WebSocketRequestLoaderData;
const now = new Date();
const categories: Record<string, (Response | WebSocketResponse)[]> = {
const categories: Record<string, ResponseType[]> = {
minutes: [],
hours: [],
today: [],
@@ -56,7 +59,7 @@ export const ResponseHistoryDropdown = ({
const deleteAllReponsesFetcher = useRequestResponseDeleteAllActionFetcher();
const handleSetActiveResponse = useCallback(
async (requestId: string, activeResponse: Response | WebSocketResponse) => {
async (requestId: string, activeResponse: ResponseType) => {
if (isWebSocketResponse(activeResponse)) {
window.main.webSocket.close({ requestId });
}
@@ -65,6 +68,10 @@ export const ResponseHistoryDropdown = ({
window.main.socketIO.close({ requestId });
}
if (isMcpResponse(activeResponse)) {
window.main.mcp.close({ requestId });
}
if (activeResponse.requestVersionId) {
await models.requestVersion.restore(activeResponse.requestVersionId);
}
@@ -80,6 +87,8 @@ export const ResponseHistoryDropdown = ({
window.main.webSocket.close({ requestId });
} else if (isSocketIOResponse(activeResponse)) {
window.main.socketIO.close({ requestId });
} else if (isMcpResponse(activeResponse)) {
window.main.socketIO.close({ requestId });
}
deleteResponsesSubmit({
organizationId,
@@ -96,12 +105,14 @@ export const ResponseHistoryDropdown = ({
window.main.webSocket.close({ requestId });
} else if (isSocketIOResponse(activeResponse)) {
window.main.socketIO.close({ requestId });
} else if (isMcpResponse(activeResponse)) {
window.main.socketIO.close({ requestId });
}
}
deleteResponseSubmit({ organizationId, projectId, workspaceId, requestId, responseId: activeResponse._id });
}, [activeResponse, deleteResponseSubmit, organizationId, projectId, workspaceId, requestId]);
responses.forEach((response: Response | WebSocketResponse) => {
responses.forEach(response => {
const responseTime = new Date(response.created);
const match =
Object.entries({
@@ -114,7 +125,7 @@ export const ResponseHistoryDropdown = ({
categories[match].push(response);
});
const renderResponseRow = (response: Response | WebSocketResponse) => {
const renderResponseRow = (response: ResponseType) => {
const activeResponseId = activeResponse ? activeResponse._id : 'n/a';
const active = response._id === activeResponseId;
const requestVersion = requestVersions.find(({ _id }) => _id === response.requestVersionId);
@@ -145,7 +156,7 @@ export const ResponseHistoryDropdown = ({
tooltipDelay={1000}
/>
<TimeTag milliseconds={response.elapsedTime} small tooltipDelay={1000} />
{!isWebSocketResponse(response) && !isSocketIOResponse(response) && (
{!isWebSocketResponse(response) && !isSocketIOResponse(response) && !isMcpResponse(response) && (
<SizeTag
bytesRead={response.bytesRead}
bytesContent={response.bytesContent}

View File

@@ -1,3 +1,4 @@
import type { IconDefinition, IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
import { library } from '@fortawesome/fontawesome-svg-core';
import { fab } from '@fortawesome/free-brands-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
@@ -6,8 +7,6 @@ import { FontAwesomeIcon, type FontAwesomeIconProps } from '@fortawesome/react-f
library.add(fas, far, fab);
import type { IconDefinition, IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
const customMcpIcon: IconDefinition = {
prefix: 'fac' as IconPrefix, // custom prefix for "custom" icons, avoids conflicts with standard prefixes
iconName: 'mcp' as IconName,

View File

@@ -0,0 +1,121 @@
import fs from 'node:fs';
import React, { useCallback } from 'react';
import { Button } from 'react-aria-components';
import { useParams } from 'react-router';
import {
getPreviewModeName,
PREVIEW_MODE_FRIENDLY,
PREVIEW_MODE_RAW,
PREVIEW_MODE_SOURCE,
PREVIEW_MODES,
} from '../../../common/constants';
import type { McpEvent } from '../../../main/network/mcp';
import { useMcpRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
import { CodeEditor } from '../../components/.client/codemirror/code-editor';
import { showError } from '../../components/modals';
import { useRequestMetaPatcher } from '../../hooks/use-request';
import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
interface Props {
event: McpEvent;
}
export const MessageEventView = ({ event }: Props) => {
const { requestId } = useParams() as { requestId: string };
const raw = JSON.stringify('data' in event ? event.data : '');
const handleDownloadResponseBody = useCallback(async () => {
const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({
title: 'Save Response Body',
buttonLabel: 'Save',
});
if (canceled || !outputPath) {
return;
}
const to = fs.createWriteStream(outputPath);
to.on('error', err => {
showError({
title: 'Save Failed',
message: 'Failed to save response body',
error: err,
});
});
to.write(raw);
to.end();
}, [raw]);
const handleCopyResponseToClipboard = useCallback(() => {
window.clipboard.writeText(raw);
}, [raw]);
const patchRequestMeta = useRequestMetaPatcher();
let pretty = raw;
try {
const parsed = JSON.parse(raw);
pretty = JSON.stringify(parsed, null, '\t');
} catch {
// Can't parse as JSON.
}
const { activeRequestMeta } = useMcpRequestLoaderData()!;
const previewMode = ('previewMode' in activeRequestMeta && activeRequestMeta.previewMode) || PREVIEW_MODE_SOURCE;
return (
<div className="flex h-full flex-col">
<div className="box-border flex h-8 flex-row border-b border-gray-300 p-2">
<Dropdown
aria-label="Websocket Preview Mode Dropdown"
triggerButton={
<Button className="tall">
{getPreviewModeName(previewMode)}
<i className="fa fa-caret-down space-left" />
</Button>
}
>
<DropdownSection aria-label="Preview Mode Section" title="Preview Mode">
{PREVIEW_MODES.map(mode => (
<DropdownItem aria-label={getPreviewModeName(mode, true)} key={mode}>
<ItemContent
icon={previewMode === mode ? 'check' : 'empty'}
label={getPreviewModeName(mode, true)}
onClick={() => patchRequestMeta(requestId, { previewMode: mode })}
/>
</DropdownItem>
))}
</DropdownSection>
<DropdownSection aria-label="Actions Section" title="Actions">
<DropdownItem aria-label="Copy raw response">
<ItemContent icon="copy" label="Copy raw response" onClick={handleCopyResponseToClipboard} />
</DropdownItem>
<DropdownItem aria-label="Export raw response">
<ItemContent icon="save" label="Export raw response" onClick={handleDownloadResponseBody} />
</DropdownItem>
</DropdownSection>
</Dropdown>
</div>
<div className="flex-grow p-4">
<CodeEditor
id="mcp-data-preview"
hideLineNumbers
mode={previewMode === PREVIEW_MODE_RAW ? 'text/plain' : 'text/json'}
defaultValue={previewMode === PREVIEW_MODE_FRIENDLY ? pretty : raw}
uniquenessKey={event._id}
readOnly
/>
</div>
</div>
);
};
export const McpEventView = ({ event }: Props) => {
if (event.type === 'message') {
return <MessageEventView event={event} />;
}
return null;
};

View File

@@ -5,7 +5,6 @@ import React, { type FC, useEffect, useRef, useState } from 'react';
import { Button, Heading, Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { PrimitiveSubItemTypes } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp';
import { InsomniaRjsfForm } from '~/ui/components/rjsf';
import { type AuthTypes } from '../../../common/constants';
@@ -18,6 +17,7 @@ import { AuthWrapper } from '../editors/auth/auth-wrapper';
import { readOnlyWebsocketPairs, RequestHeadersEditor } from '../editors/request-headers-editor';
import { Pane } from '../panes/pane';
import { McpUrlActionBar } from './mcp-url-bar';
import type { PrimitiveSubItem } from './types';
const supportedAuthTypes: AuthTypes[] = ['apikey', 'basic', 'bearer'];
@@ -47,7 +47,7 @@ const PaneReadOnlyBanner = () => {
interface Props {
environment: Environment | null;
readyState: boolean;
selectedPrimitiveItem?: PrimitiveSubItemTypes | null;
selectedPrimitiveItem?: PrimitiveSubItem | null;
}
export const McpRequestPane: FC<Props> = ({ environment, readyState, selectedPrimitiveItem }) => {

View File

@@ -0,0 +1,18 @@
import type { Prompt, Resource, ResourceTemplate, Tool } from '@modelcontextprotocol/sdk/types.js';
import type { McpServerPrimitiveTypes } from '../../../models/mcp-request';
interface CommonItemProps {
itemLevel: number;
hide: boolean;
}
export type ToolItem = Tool & { type: 'tools' } & CommonItemProps;
export type ResourceItem = Resource & { type: 'resources' } & CommonItemProps;
export type ResourceTemplateItem = ResourceTemplate & { type: 'resources' } & CommonItemProps;
export type PromptItem = Prompt & { type: 'prompts' } & CommonItemProps;
export type PrimitiveSubItem = ToolItem | ResourceItem | ResourceTemplateItem | PromptItem;
export interface PrimitiveTypeItem extends CommonItemProps {
type: McpServerPrimitiveTypes;
name: string;
}

View File

@@ -39,7 +39,8 @@ interface Props {
activeRequestId: string;
}
export const ResponsePane: FC<Props> = ({ activeRequestId }) => {
const { activeRequest, activeRequestMeta, activeResponse } = useRequestLoaderData() as RequestLoaderData;
const { activeRequest, activeRequestMeta, activeResponse, responses, requestVersions } =
useRequestLoaderData() as RequestLoaderData;
const filterHistory = activeRequestMeta.responseFilterHistory || [];
const filter = activeRequestMeta.responseFilter || '';
const patchRequestMeta = useRequestMetaPatcher();
@@ -161,7 +162,11 @@ export const ResponsePane: FC<Props> = ({ activeRequestId }) => {
<TimeTag milliseconds={activeResponse.elapsedTime} steps={steps} />
<SizeTag bytesRead={activeResponse.bytesRead} bytesContent={activeResponse.bytesContent} />
</div>
<ResponseHistoryDropdown activeResponse={activeResponse} />
<ResponseHistoryDropdown
activeResponse={activeResponse}
responses={responses}
requestVersions={requestVersions}
/>
</PaneHeader>
)}
<Tabs aria-label="Request group tabs" className="flex h-full w-full flex-1 flex-col">

View File

@@ -4,26 +4,30 @@ import React, { type FC, useEffect, useRef } from 'react';
import { Cell, Column, Row, Table, TableBody, TableHeader } from 'react-aria-components';
import type { CurlEvent } from '../../../main/network/curl';
import type { McpEvent } from '../../../main/network/mcp';
import type { SocketIOEvent } from '../../../main/network/socket-io';
import type { WebSocketEvent } from '../../../main/network/websocket';
import { type IconId, SvgIcon } from '../svg-icon';
type EventTypes = WebSocketEvent | CurlEvent | SocketIOEvent | McpEvent;
const Timestamp: FC<{ time: Date | number }> = ({ time }) => {
const date = format(time, 'HH:mm:ss');
return <>{date}</>;
};
interface Props {
events: (WebSocketEvent | CurlEvent | SocketIOEvent)[];
events: EventTypes[];
selectionId?: string;
onSelect: (event: WebSocketEvent | CurlEvent | SocketIOEvent) => void;
onSelect: (event: EventTypes) => void;
}
const isSocketIOEvent = (event: WebSocketEvent | CurlEvent | SocketIOEvent): event is SocketIOEvent => {
const isSocketIOEvent = (event: EventTypes): event is SocketIOEvent => {
return 'eventName' in event && typeof event.eventName === 'string';
};
function getIcon(event: WebSocketEvent | CurlEvent | SocketIOEvent): IconId {
const isMcpEvent = (event: EventTypes): event is McpEvent => event._id.toString().startsWith('mcp-');
function getIcon(event: EventTypes): IconId {
switch (event.type) {
case 'message': {
if (event.direction === 'OUTGOING') {
@@ -55,7 +59,7 @@ function getIcon(event: WebSocketEvent | CurlEvent | SocketIOEvent): IconId {
}
}
const getMessage = (event: WebSocketEvent | CurlEvent | SocketIOEvent): string | JSX.Element => {
const getMessage = (event: EventTypes): string | JSX.Element => {
switch (event.type) {
case 'message': {
if (isSocketIOEvent(event)) {
@@ -71,6 +75,11 @@ const getMessage = (event: WebSocketEvent | CurlEvent | SocketIOEvent): string |
</div>
);
}
if (isMcpEvent(event)) {
if ('method' in event) {
return <pre className="whitespace-pre-wrap">{event.method}</pre>;
}
}
if ('data' in event && typeof event.data === 'object') {
return 'Binary data';
}

View File

@@ -4,22 +4,26 @@ import React, { type FC, useEffect, useMemo, useState } from 'react';
import { Button, Input, SearchField, Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useMcpRequestLoaderData } from '../../..//routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
import { getSetCookieHeaders } from '../../../common/misc';
import type { CurlEvent } from '../../../main/network/curl';
import type { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import type { McpEvent } from '../../../main/network/mcp';
import type { SocketIOEvent } from '../../../main/network/socket-io';
import type { WebSocketEvent } from '../../../main/network/websocket';
import { isMcpResponse, type McpResponse } from '../../../models/mcp-response';
import type { RequestVersion } from '../../../models/request-version';
import type { Response } from '../../../models/response';
import { isSocketIOResponse, type SocketIOResponse } from '../../../models/socket-io-response';
import type { WebSocketResponse } from '../../../models/websocket-response';
import { type WebSocketResponse } from '../../../models/websocket-response';
import { useRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import { useMcpRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
import { deserializeNDJSON } from '../../../utils/ndjson';
import { useReadyState } from '../../hooks/use-ready-state';
import { useRealtimeConnectionEvents } from '../../hooks/use-realtime-connection-events';
import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown';
import { ErrorBoundary } from '../error-boundary';
import { Icon } from '../icon';
import { McpEventView } from '../mcp/event-view';
import { Pane, PaneHeader } from '../panes/pane';
import { PlaceholderResponsePane } from '../panes/placeholder-response-pane';
import { SocketIOEventView } from '../socket-io/event-view';
@@ -35,7 +39,7 @@ import { EventLogView } from './event-log-view';
import { EventView } from './event-view';
export const RealtimeResponsePane: FC<{ requestId: string }> = () => {
const { activeResponse } = useRequestLoaderData()!;
const { activeResponse, responses, requestVersions } = useRequestLoaderData()!;
if (!activeResponse) {
return (
@@ -45,11 +49,13 @@ export const RealtimeResponsePane: FC<{ requestId: string }> = () => {
</Pane>
);
}
return <RealtimeActiveResponsePane response={activeResponse} />;
return (
<RealtimeActiveResponsePane response={activeResponse} responses={responses} requestVersions={requestVersions} />
);
};
export const McpRealtimeResponsePane = () => {
const { activeResponse } = useMcpRequestLoaderData()!;
const { activeResponse, responses, requestVersions } = useMcpRequestLoaderData()!;
if (!activeResponse) {
return (
@@ -59,13 +65,19 @@ export const McpRealtimeResponsePane = () => {
</Pane>
);
}
return <RealtimeActiveResponsePane response={activeResponse} />;
return (
<RealtimeActiveResponsePane response={activeResponse} responses={responses} requestVersions={requestVersions} />
);
};
type ResponseType = WebSocketResponse | Response | SocketIOResponse | McpResponse;
type EventType = CurlEvent | WebSocketEvent | SocketIOEvent | McpEvent;
const RealtimeActiveResponsePane: FC<{
response: WebSocketResponse | Response | SocketIOResponse;
}> = ({ response }) => {
const [selectedEvent, setSelectedEvent] = useState<CurlEvent | WebSocketEvent | SocketIOEvent | null>(null);
response: ResponseType;
responses: ResponseType[];
requestVersions: RequestVersion[];
}> = ({ response, responses, requestVersions }) => {
const [selectedEvent, setSelectedEvent] = useState<EventType | null>(null);
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
const [clearEventsBefore, setClearEventsBefore] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -75,20 +87,26 @@ const RealtimeActiveResponsePane: FC<{
if (isSocketIOResponse(response)) {
return 'socketIO';
}
if (isMcpResponse(response)) {
return 'mcp';
}
return response.type === 'WebSocketResponse' ? 'webSocket' : 'curl';
}, [response]);
const allEvents = useRealtimeConnectionEvents({ responseId: response._id, protocol }) as (
| CurlEvent
| WebSocketEvent
| SocketIOEvent
)[];
const allEvents = useRealtimeConnectionEvents({ responseId: response._id, protocol }) as EventType[];
const requestId = response.parentId;
const readyState = useReadyState({ requestId: requestId, protocol });
const handleSelection = (event: CurlEvent | WebSocketEvent | SocketIOEvent) => {
setSelectedEvent((selected: CurlEvent | WebSocketEvent | SocketIOEvent | null) =>
selected?._id === event._id ? null : event,
);
const handleSelection = (event: EventType) => {
setSelectedEvent((selected: EventType | null) => (selected?._id === event._id ? null : event));
};
const getEventView = (selectedEvent: EventType) => {
if (isSocketIOResponse(response)) {
return <SocketIOEventView event={selectedEvent as SocketIOEvent} key={selectedEvent._id} />;
} else if (isMcpResponse(response)) {
return <McpEventView event={selectedEvent as McpEvent} key={selectedEvent._id} />;
}
return <EventView event={selectedEvent as WebSocketEvent} key={selectedEvent._id} />;
};
const events = useMemo(
@@ -157,7 +175,8 @@ const RealtimeActiveResponsePane: FC<{
};
}, [response.timelinePath, events.length]);
const cookieHeaders = !isSocketIOResponse(response) ? getSetCookieHeaders(response.headers) : [];
const cookieHeaders =
!isSocketIOResponse(response) && !isMcpResponse(response) ? getSetCookieHeaders(response.headers) : [];
return (
<Pane type="response">
<PaneHeader className="row-spaced">
@@ -174,7 +193,7 @@ const RealtimeActiveResponsePane: FC<{
</>
)}
</div>
<ResponseHistoryDropdown activeResponse={response} />
<ResponseHistoryDropdown activeResponse={response} requestVersions={requestVersions} responses={responses} />
</PaneHeader>
<Tabs aria-label="Request group tabs" className="flex h-full w-full flex-1 flex-col">
<TabList
@@ -284,13 +303,7 @@ const RealtimeActiveResponsePane: FC<{
<>
<PanelResizeHandle className={'h-[1px] w-full bg-[--hl-md]'} />
<Panel minSize={10} defaultSize={50}>
<div className="h-full flex-1 border-t border-[var(--hl-md)]">
{isSocketIOResponse(response) ? (
<SocketIOEventView key={selectedEvent._id} event={selectedEvent as SocketIOEvent} />
) : (
<EventView key={selectedEvent._id} event={selectedEvent} />
)}
</div>
<div className="h-full flex-1 border-t border-[var(--hl-md)]">{getEventView(selectedEvent)}</div>
</Panel>
</>
)}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { CurlEvent } from '../../main/network/curl';
import type { McpEvent } from '../../main/network/mcp';
import type { SocketIOEvent } from '../../main/network/socket-io';
import type { WebSocketEvent } from '../../main/network/websocket';
@@ -11,7 +12,7 @@ export function useRealtimeConnectionEvents({
responseId: string;
protocol: 'curl' | 'webSocket' | 'socketIO' | 'mcp';
}) {
const [events, setEvents] = useState<CurlEvent[] | WebSocketEvent[] | SocketIOEvent[]>([]);
const [events, setEvents] = useState<CurlEvent[] | WebSocketEvent[] | SocketIOEvent[] | McpEvent[]>([]);
const updateEvents = useCallback(async () => {
const allEvents = await window.main[protocol].event.findMany({ responseId });
setEvents(allEvents);