diff --git a/package-lock.json b/package-lock.json index 21d8264a128..b59708d1687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4109,6 +4109,332 @@ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", + "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "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", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "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", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "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", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "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" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "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" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "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", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "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", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "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" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "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" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "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" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "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" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { + "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" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "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", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body/node_modules/iconv-lite": { + "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" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "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", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "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", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "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", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7162,6 +7488,69 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@rjsf/core": { + "version": "6.0.0-beta.15", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.0.0-beta.15.tgz", + "integrity": "sha512-VF6o/tetnYxgdv19qDgTdElkLQlPHH/2yn0Tyz7J8lUVucmYuPEctV6qo88NJUAtvts9EFLr7SYKaMQpfNJonQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.7.13", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.0.0-beta.15", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.0.0-beta.15", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.0.0-beta.15.tgz", + "integrity": "sha512-AWF0/OVRpdkxNrp1JIrTL+ryzGGffm/IXSICq+c0Ju3HIdcV0kvRDrlQtmdgbIbjdcWogS1Gg97cFhk/DzgjhQ==", + "license": "Apache-2.0", + "dependencies": { + "fast-uri": "^3.0.6", + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.0.0-beta.15", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.0.0-beta.15.tgz", + "integrity": "sha512-M40hmygBfR8NlaR3JEiBJIqs03Qli7F5DRS7ypFDBDsZLcSm1Kd6BWbjC3EM2IutYyTgxg6lvi1lQw95PqqcYw==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.0.0-beta.15" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -12679,6 +13068,27 @@ "node": ">= 0.6" } }, + "node_modules/compute-gcd": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/compute-lcm": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", + "dependencies": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -15848,6 +16258,29 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "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" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "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" + } + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -15940,6 +16373,22 @@ "basic-auth": "^2.0.1" } }, + "node_modules/express-rate-limit": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -18948,6 +19397,29 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.4" + } + }, + "node_modules/json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", + "license": "MIT", + "dependencies": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-schema-ref-parser": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", @@ -19673,6 +20145,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -19947,6 +20425,23 @@ "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" }, + "node_modules/markdown-to-jsx": { + "version": "7.7.15", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.15.tgz", + "integrity": "sha512-U5dw5oRajrPTE2oJQWAbLK8RgbCDJ264AjW3fGABq+/rZjQ0E/WGVCLKAHvpKHQFUwoWoK8ZZWVPNLR/biYMhg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/marked": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", @@ -22399,6 +22894,16 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "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" + } + }, "node_modules/playwright": { "version": "1.55.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", @@ -22912,7 +23417,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -22924,7 +23428,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proto3-json-serializer": { @@ -23750,6 +24253,51 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "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", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/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" + } + }, + "node_modules/router/node_modules/is-promise": { + "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", + "url": "https://opencollective.com/express" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -26598,6 +27146,39 @@ "builtins": "^1.0.3" } }, + "node_modules/validate.io-array": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", + "license": "MIT" + }, + "node_modules/validate.io-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==" + }, + "node_modules/validate.io-integer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", + "dependencies": { + "validate.io-number": "^1.0.3" + } + }, + "node_modules/validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "node_modules/validate.io-number": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==" + }, "node_modules/validator": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", @@ -27534,6 +28115,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "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" + } + }, "packages/insomnia": { "version": "11.6.1", "license": "Apache-2.0", @@ -27558,6 +28149,9 @@ "@react-router/fs-routes": "^7.7.0", "@react-router/node": "^7.7.0", "@react-router/serve": "^7.7.0", + "@rjsf/core": "^6.0.0-beta.15", + "@rjsf/utils": "^6.0.0-beta.15", + "@rjsf/validator-ajv8": "^6.0.0-beta.15", "@seald-io/nedb": "^4.1.1", "@segment/analytics-node": "2.2.1", "@sentry/electron": "^6.5.0", @@ -27648,6 +28242,7 @@ }, "devDependencies": { "@develohpanda/fluent-builder": "^2.1.2", + "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "^7.7.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 9a629931bb8..f9021f13774 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -59,6 +59,9 @@ "@react-router/serve": "^7.7.0", "@seald-io/nedb": "^4.1.1", "@segment/analytics-node": "2.2.1", + "@rjsf/core": "^6.0.0-beta.15", + "@rjsf/utils": "^6.0.0-beta.15", + "@rjsf/validator-ajv8": "^6.0.0-beta.15", "@sentry/electron": "^6.5.0", "@stoplight/spectral-core": "^1.20.0", "@stoplight/spectral-formats": "^1.8.2", @@ -144,6 +147,7 @@ }, "devDependencies": { "@develohpanda/fluent-builder": "^2.1.2", + "@modelcontextprotocol/sdk": "^1.17.5", "@react-router/dev": "^7.7.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index ea8557b810e..815cba88034 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -550,3 +550,9 @@ export const RESPONSE_CODE_REASONS: Record = { // (ms) curently server timeout is 30s export const INSOMNIA_FETCH_TIME_OUT = 30_000; + +// channel names for real time events (websocket/socket-io/mcp) +export const REALTIME_EVENTS_CHANNELS = { + READY_STATE: 'readyState', + NEW_EVENT: 'newEventReceived', +}; diff --git a/packages/insomnia/src/common/database.ts b/packages/insomnia/src/common/database.ts index 6aeeaa7bfc7..3462dd7a6ce 100644 --- a/packages/insomnia/src/common/database.ts +++ b/packages/insomnia/src/common/database.ts @@ -285,6 +285,18 @@ export const database = { ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.MockServer.db'), }), + McpRequest: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.McpRequest.db'), + }), + McpResponse: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.McpResponse.db'), + }), + McpPayload: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.McpPayload.db'), + }), OAuth2Token: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.OAuth2Token.db'), diff --git a/packages/insomnia/src/common/get-workspace-label.ts b/packages/insomnia/src/common/get-workspace-label.ts index 281d03423be..cc89601203c 100644 --- a/packages/insomnia/src/common/get-workspace-label.ts +++ b/packages/insomnia/src/common/get-workspace-label.ts @@ -1,4 +1,4 @@ -import { isDesign, isEnvironment, isMockServer, type Workspace } from '../models/workspace'; +import { isDesign, isEnvironment, isMcp, isMockServer, type Workspace } from '../models/workspace'; import { strings } from './strings'; export const getWorkspaceLabel = (workspace: Workspace) => { @@ -14,5 +14,9 @@ export const getWorkspaceLabel = (workspace: Workspace) => { return strings.environment; } + if (isMcp(workspace)) { + return strings.mcp; + } + return strings.collection; }; diff --git a/packages/insomnia/src/common/import-v5-parser.ts b/packages/insomnia/src/common/import-v5-parser.ts index 103d3d86348..8460f17d279 100644 --- a/packages/insomnia/src/common/import-v5-parser.ts +++ b/packages/insomnia/src/common/import-v5-parser.ts @@ -145,7 +145,14 @@ const ApiKeyAuthenticationSchema = z.object({ const OAuth2AuthenticationSchema = z.object({ type: z.literal('oauth2'), disabled: z.boolean().optional(), - grantType: z.enum(['authorization_code', 'client_credentials', 'implicit', 'password', 'refresh_token']), + grantType: z.enum([ + 'authorization_code', + 'client_credentials', + 'implicit', + 'password', + 'refresh_token', + 'mcp-auth-flow', + ]), accessTokenUrl: z.string().optional(), authorizationUrl: z.string().optional(), clientId: z.string().optional(), diff --git a/packages/insomnia/src/common/mcp-utils.ts b/packages/insomnia/src/common/mcp-utils.ts new file mode 100644 index 00000000000..4b002fbb4cf --- /dev/null +++ b/packages/insomnia/src/common/mcp-utils.ts @@ -0,0 +1,205 @@ +import { UriTemplate } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializeRequestSchema, + InitializeResultSchema, + type JSONRPCMessage, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + ProgressNotificationSchema, + type Prompt, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + type Resource, + ResourceListChangedNotificationSchema, + type ResourceTemplate, + ResourceUpdatedNotificationSchema, + type ServerCapabilities, + ServerNotificationSchema, + ServerRequestSchema, + SubscribeRequestSchema, + type Tool, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { RJSFSchema } from '@rjsf/utils'; + +// methods for server features +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; +export const METHOD_SUBSCRIBE_RESOURCE = SubscribeRequestSchema.shape.method.value; +export const METHOD_UNSUBSCRIBE_RESOURCE = UnsubscribeRequestSchema.shape.method.value; +// methods for client features +export const METHOD_SAMPLING_CREATE_MESSAGE = CreateMessageRequestSchema.shape.method.value; +export const METHOD_LIST_ROOTS = ListRootsRequestSchema.shape.method.value; +export const METHOD_ELICITATION_CREATE_MESSAGE = ElicitRequestSchema.shape.method.value; +// methods for notifications +export const METHOD_NOTIFICATION_CANCELLED = CancelledNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_PROGRESS = ProgressNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_LOGGING_MESSAGE = LoggingMessageNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_RESOURCE_UPDATED = ResourceUpdatedNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_RESOURCE_LIST_CHANGED = ResourceListChangedNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_TOOL_LIST_CHANGED = ToolListChangedNotificationSchema.shape.method.value; +export const METHOD_NOTIFICATION_PROMPT_LIST_CHANGED = PromptListChangedNotificationSchema.shape.method.value; + +export const unsupportedMethodPrefix = 'Unsupported/'; +export const METHOD_UNKNOWN = 'Unknown Method'; +export const NOTIFICATION_METHODS = [ + METHOD_NOTIFICATION_CANCELLED, + METHOD_NOTIFICATION_PROGRESS, + METHOD_NOTIFICATION_LOGGING_MESSAGE, + METHOD_NOTIFICATION_RESOURCE_UPDATED, + METHOD_NOTIFICATION_RESOURCE_LIST_CHANGED, + METHOD_NOTIFICATION_TOOL_LIST_CHANGED, + METHOD_NOTIFICATION_PROMPT_LIST_CHANGED, +] as const; +export const CLIENT_METHODS = [ + METHOD_SAMPLING_CREATE_MESSAGE, + METHOD_LIST_ROOTS, + METHOD_ELICITATION_CREATE_MESSAGE, +] as const; +export const SERVER_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 const NOTIFICATIONS_LIST_CHANGED = [ + METHOD_NOTIFICATION_RESOURCE_LIST_CHANGED, + METHOD_NOTIFICATION_TOOL_LIST_CHANGED, + METHOD_NOTIFICATION_PROMPT_LIST_CHANGED, +]; + +export type McpServerMethods = (typeof SERVER_METHODS)[number]; +export type NotificationMethods = (typeof NOTIFICATION_METHODS)[number]; +export type McpClientMethods = (typeof CLIENT_METHODS)[number]; +export type UnsupportedMcpClientMethods = `${typeof unsupportedMethodPrefix}${string}`; + +export type JSONRPCMessageMethods = McpServerMethods | McpClientMethods | NotificationMethods; +export interface McpServerData { + serverCapabilities: ServerCapabilities; + primitives: { + tools: Tool[]; + resources: Resource[]; + resourceTemplates: ResourceTemplate[]; + prompts: Prompt[]; + }; +} + +type McpMessageEventMethods = JSONRPCMessageMethods | typeof METHOD_UNKNOWN | UnsupportedMcpClientMethods; +export const getMcpMethodFromMessage = (message: JSONRPCMessage): McpMessageEventMethods => { + let method: McpMessageEventMethods = 'Unknown Method'; + if (ServerNotificationSchema.safeParse(message).success) { + // for server notification messages + method = ServerNotificationSchema.parse(message).method; + } else 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 (GetPromptResultSchema.safeParse(messageResult).success) { + method = METHOD_GET_PROMPT; + } else if (ReadResourceResultSchema.safeParse(messageResult).success) { + method = METHOD_READ_RESOURCE; + } else if (CallToolResultSchema.safeParse(messageResult).success) { + method = METHOD_CALL_TOOL; + } + } else if (ServerRequestSchema.safeParse(message).success) { + const requestMethod = ServerRequestSchema.parse(message).method; + if (requestMethod === METHOD_LIST_ROOTS) { + method = METHOD_LIST_ROOTS; + } else { + // Do not support any server requests to client including ping, elicitation and sampling + method = `${unsupportedMethodPrefix}${ServerRequestSchema.parse(message).method}`; + } + } + return method; +}; + +export const getDefaultServerCapabilities = () => { + return { + tools: { + enabled: false, + listChanged: false, + }, + resources: { + enabled: false, + listChanged: false, + subscribe: true, + }, + prompts: { + enabled: false, + listChanged: false, + }, + }; +}; + +export const isResourceTemplate = (resource: Resource | ResourceTemplate): resource is ResourceTemplate => { + return 'uriTemplate' in resource && resource.uriTemplate !== undefined; +}; + +export const buildResourceJsonSchema = (resource: Resource | ResourceTemplate): RJSFSchema => { + if (isResourceTemplate(resource)) { + const uriTemplate = new UriTemplate(resource.uriTemplate); + const properties: Record = {}; + const required: string[] = []; + uriTemplate.variableNames.forEach(name => { + properties[name] = { + type: 'string', + }; + required.push(name); + }); + return { + type: 'object', + properties, + required, + }; + } + return { + type: 'object', + properties: { + uri: { + type: 'string', + default: resource.uri, + }, + }, + required: ['uri'], + readOnly: true, + }; +}; + +export const fillUriTemplate = (template: string, values: Record): string => { + return new UriTemplate(template).expand(values); +}; diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index 5299dade940..d6af7135a70 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -9,6 +9,7 @@ import { vaultEnvironmentRuntimePath, } from '../models/environment'; import type { GrpcRequest, GrpcRequestBody } from '../models/grpc-request'; +import type { McpRequest } from '../models/mcp-request'; import { isProject } from '../models/project'; import { PATH_PARAMETER_REGEX, type Request } from '../models/request'; import { isRequestGroup, type RequestGroup } from '../models/request-group'; @@ -704,12 +705,13 @@ function _getOrderedEnvironmentKeys(finalRenderContext: Record): st } export async function getRenderContextAncestors( - base?: Request | GrpcRequest | WebSocketRequest | SocketIORequest | RequestGroup | Workspace, + base?: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest | RequestGroup | Workspace, ): Promise { return await db.withAncestors(base, [ models.request.type, models.grpcRequest.type, models.webSocketRequest.type, + models.mcpRequest.type, models.requestGroup.type, models.workspace.type, models.project.type, diff --git a/packages/insomnia/src/common/strings.ts b/packages/insomnia/src/common/strings.ts index c51693be5cc..9a976500bb2 100644 --- a/packages/insomnia/src/common/strings.ts +++ b/packages/insomnia/src/common/strings.ts @@ -13,7 +13,8 @@ type StringId = | 'defaultProject' | 'localProject' | 'remoteProject' - | 'environment'; + | 'environment' + | 'mcp'; export const strings: Record = { collection: { @@ -56,4 +57,8 @@ export const strings: Record = { singular: 'Environment', plural: 'Environments', }, + mcp: { + singular: 'MCP Client', + plural: 'MCP Clients', + }, }; diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index 7fb7e5fb48d..9a6eda7d7c9 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -20,6 +20,7 @@ import { registerMainHandlers } from './main/ipc/main'; import { registerSecretStorageHandlers } from './main/ipc/secret-storage'; import log, { initializeLogging } from './main/log'; import { registerCurlHandlers } from './main/network/curl'; +import { registerMcpHandlers } from './main/network/mcp'; import { registerSocketIOHandlers } from './main/network/socket-io'; import { registerWebSocketHandlers } from './main/network/websocket'; import { watchProxySettings } from './main/proxy'; @@ -74,6 +75,7 @@ app.on('ready', async () => { registerWebSocketHandlers(); registerSocketIOHandlers(); registerCurlHandlers(); + registerMcpHandlers(); registerSecretStorageHandlers(); /** diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 30d4912dc0f..25a11aab9e0 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -4,6 +4,7 @@ import type { GitServiceAPI } from './main/git-service'; import type { gRPCBridgeAPI } from './main/ipc/grpc'; import type { secretStorageBridgeAPI } from './main/ipc/secret-storage'; import type { CurlBridgeAPI } from './main/network/curl'; +import type { McpBridgeAPI } from './main/network/mcp'; import type { SocketIOBridgeAPI } from './main/network/socket-io'; import type { WebSocketBridgeAPI } from './main/network/websocket'; import { invariant } from './utils/invariant'; @@ -49,6 +50,33 @@ const socketIO: SocketIOBridgeAPI = { }, }; +const mcp: McpBridgeAPI = { + connect: options => ipcRenderer.invoke('mcp.connect', options), + close: options => ipcRenderer.invoke('mcp.close', options), + closeAll: () => ipcRenderer.send('mcp.closeAll'), + authConfirmation: confirmed => ipcRenderer.send('mcp.authConfirmed', confirmed), + primitive: { + listTools: options => ipcRenderer.invoke('mcp.primitive.listTools', options), + callTool: options => ipcRenderer.invoke('mcp.primitive.callTool', options), + listResources: options => ipcRenderer.invoke('mcp.primitive.listResources', options), + listResourceTemplates: options => ipcRenderer.invoke('mcp.primitive.listResourceTemplates', options), + readResource: options => ipcRenderer.invoke('mcp.primitive.readResource', options), + subscribeResource: options => ipcRenderer.invoke('mcp.primitive.subscribeResource', options), + unsubscribeResource: options => ipcRenderer.invoke('mcp.primitive.unsubscribeResource', options), + listPrompts: options => ipcRenderer.invoke('mcp.primitive.listPrompts', options), + getPrompt: options => ipcRenderer.invoke('mcp.primitive.getPrompt', options), + }, + notification: { + rootListChange: options => ipcRenderer.invoke('mcp.notification.rootListChange', options), + }, + readyState: { + getCurrent: options => ipcRenderer.invoke('mcp.readyState', options), + }, + event: { + findMany: options => ipcRenderer.invoke('mcp.event.findMany', options), + }, +}; + const grpc: gRPCBridgeAPI = { start: options => ipcRenderer.send('grpc.start', options), sendMessage: options => ipcRenderer.send('grpc.sendMessage', options), @@ -144,6 +172,7 @@ const main: Window['main'] = { }, webSocket, socketIO, + mcp, git, grpc, curl, diff --git a/packages/insomnia/src/main/analytics.ts b/packages/insomnia/src/main/analytics.ts index 1b2a65289c2..949f97ca620 100644 --- a/packages/insomnia/src/main/analytics.ts +++ b/packages/insomnia/src/main/analytics.ts @@ -59,6 +59,11 @@ export enum SegmentEvent { vcsSyncComplete = 'VCS Sync Completed', vcsAction = 'VCS Action Executed', buttonClick = 'Button Clicked', + mcpClientConnected = 'MCP Client Connected', + mcpClientDisconnected = 'MCP Client Disconnected', + mcpToolCalled = 'MCP Tool Called', + mcpResourceRead = 'MCP Resource Read', + mcpPromptCalled = 'MCP Prompt Called', } function hashString(input: string) { diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 2df6ca76317..e493a3193ed 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -94,7 +94,21 @@ export type HandleChannels = | 'webSocket.event.send' | 'webSocket.open' | 'webSocket.readyState' - | 'writeFile'; + | 'writeFile' + | 'mcp.connect' + | 'mcp.primitive.listTools' + | 'mcp.primitive.callTool' + | 'mcp.primitive.listPrompts' + | 'mcp.primitive.getPrompt' + | 'mcp.primitive.listResources' + | 'mcp.primitive.listResourceTemplates' + | 'mcp.primitive.readResource' + | 'mcp.primitive.subscribeResource' + | 'mcp.primitive.unsubscribeResource' + | 'mcp.notification.rootListChange' + | 'mcp.readyState' + | 'mcp.event.findMany' + | 'mcp.close'; export const ipcMainHandle = ( channel: HandleChannels, @@ -137,6 +151,8 @@ export type MainOnChannels = | 'updateLatestStepName' | 'webSocket.close' | 'webSocket.closeAll' + | 'mcp.closeAll' + | 'mcp.sendMCPRequest' | 'writeText'; export type RendererOnChannels = @@ -157,7 +173,11 @@ export type RendererOnChannels = | 'show-toast' | 'toggle-preferences-shortcuts' | 'toggle-preferences' - | 'toggle-sidebar'; + | 'toggle-sidebar' + | 'show-oauth-authorization-modal' + | 'hide-oauth-authorization-modal' + | 'mcp-auth-confirmation' + | 'updaterStatus'; export const ipcMainOn = ( channel: MainOnChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index a57e50495b6..253bef5caef 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -34,6 +34,7 @@ import type { GitServiceAPI } from '../git-service'; import installPlugin from '../install-plugin'; import type { CurlBridgeAPI } from '../network/curl'; import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise'; +import type { McpBridgeAPI } from '../network/mcp'; import { addExecutionStep, completeExecutionStep, @@ -80,6 +81,7 @@ export interface RendererToMainBridgeAPI { on: (channel: RendererOnChannels, listener: (event: IpcRendererEvent, ...args: any[]) => void) => () => void; webSocket: WebSocketBridgeAPI; socketIO: SocketIOBridgeAPI; + mcp: McpBridgeAPI; grpc: gRPCBridgeAPI; curl: CurlBridgeAPI; git: GitServiceAPI; diff --git a/packages/insomnia/src/main/network/mcp.ts b/packages/insomnia/src/main/network/mcp.ts new file mode 100644 index 00000000000..4d5a8cb5845 --- /dev/null +++ b/packages/insomnia/src/main/network/mcp.ts @@ -0,0 +1,1223 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + auth, + type AuthResult, + extractResourceMetadataUrl, + type OAuthClientProvider, + UnauthorizedError, +} from '@modelcontextprotocol/sdk/client/auth.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + type OAuthClientInformationFull, + OAuthClientInformationSchema, + type OAuthClientMetadata, + type OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { GetPromptRequest, Notification, ReadResourceRequest } from '@modelcontextprotocol/sdk/types.js'; +import { + type ClientRequest, + CompatibilityCallToolResultSchema, + ElicitResultSchema, + EmptyResultSchema, + InitializeRequestSchema, + isInitializeRequest, + JSONRPCErrorSchema, + type JSONRPCMessage, + type JSONRPCRequest, + type JSONRPCResponse, + type ListPromptsRequest, + type ListResourcesRequest, + ListRootsRequestSchema, + ListRootsResultSchema, + ServerNotificationSchema, + type SubscribeRequest, + type UnsubscribeRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import electron, { BrowserWindow, ipcMain } from 'electron'; +import { parse } from 'shell-quote'; +import { v4 as uuidV4 } from 'uuid'; +import type { z } from 'zod'; + +import { getAppVersion, getOauthRedirectUrl, getProductName, REALTIME_EVENTS_CHANNELS } from '~/common/constants'; +import { + getMcpMethodFromMessage, + METHOD_ELICITATION_CREATE_MESSAGE, + METHOD_LIST_ROOTS, + METHOD_SUBSCRIBE_RESOURCE, + METHOD_UNKNOWN, + METHOD_UNSUBSCRIBE_RESOURCE, +} from '~/common/mcp-utils'; +import { generateId } from '~/common/misc'; +import { SegmentEvent, trackSegmentEvent } from '~/main/analytics'; +import { authorizeUserInDefaultBrowser } from '~/main/authorizeUserInDefaultBrowser'; +import * as models from '~/models'; +import { type McpRequest, TRANSPORT_TYPES, type TransportType } from '~/models/mcp-request'; +import { type McpResponse, prefix as mcpResponsePrefix } from '~/models/mcp-response'; +import type { RequestAuthentication, RequestHeader } from '~/models/request'; +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 | StdioClientTransport }; +// Mcp connection and request options +interface CommonMcpOptions { + requestId: string; +} +type OpenMcpHTTPClientConnectionOptions = CommonMcpOptions & { + workspaceId: string; + url: string; + transportType: typeof TRANSPORT_TYPES.HTTP; + headers: RequestHeader[]; + authentication: RequestAuthentication; +}; +type OpenMcpStdioClientConnectionOptions = CommonMcpOptions & { + workspaceId: string; + // TODO: should rename to command or urlOrCommand + url: string; + transportType: typeof TRANSPORT_TYPES.STDIO; + env: Record; +}; +export type OpenMcpClientConnectionOptions = OpenMcpHTTPClientConnectionOptions | OpenMcpStdioClientConnectionOptions; +const isOpenMcpHTTPClientConnectionOptions = ( + options: OpenMcpClientConnectionOptions, +): options is OpenMcpHTTPClientConnectionOptions => { + return options.transportType === TRANSPORT_TYPES.HTTP; +}; +export interface McpRequestOptions { + requestId: string; + request: ClientRequest; + schema: z.ZodType; + signal?: AbortSignal; +} +interface CallToolOptions extends CommonMcpOptions { + name: string; + parameters: Record; +} + +interface McpEventBase { + _id: string; + requestId: string; + timestamp: number; +} +interface McpCloseEventWithoutBase { + type: 'close'; + reason: string; +} +interface McpMessageEventWithoutBase { + type: 'message'; + direction: 'INCOMING'; + data: JSONRPCResponse | {}; + method: string; +} +export type McpMessageEvent = McpEventBase & McpMessageEventWithoutBase; +interface McpErrorEventWithoutBase { + type: 'error'; + message: string; + error: any; +} +interface McpRequestEventWithoutBase { + type: 'message'; + direction: 'OUTGOING'; + method: string; + data: any; +} +interface McpNotificationEventWithoutBase { + type: 'notification'; + method: string; + direction: 'INCOMING'; + data: Notification; +} +interface McpAuthEventWithoutBase { + type: 'message'; + method: 'MCP Auth'; + direction: 'OUTGOING' | 'INCOMING'; + data: Record; +} +export type McpNotificationEvent = McpEventBase & McpNotificationEventWithoutBase; +type McpEventWithoutBase = + | McpMessageEventWithoutBase + | McpRequestEventWithoutBase + | McpCloseEventWithoutBase + | McpErrorEventWithoutBase + | McpNotificationEventWithoutBase + | McpAuthEventWithoutBase; +export type McpEvent = McpEventBase & McpEventWithoutBase; +interface ResponseEventOptions { + responseId: string; + requestId: string; + environmentId: string | null; + timelinePath: string; + eventLogPath: string; + authProvider: McpOAuthClientProvider; +} + +const mcpConnections = new Map(); +const eventLogFileStreams = new Map(); +const timelineFileStreams = new Map(); +const requestIdToResponseIdMap = new Map(); + +const protocol = 'mcp'; +const getMcpStateChannel = (requestId: string) => `${protocol}.${requestId}.${REALTIME_EVENTS_CHANNELS.READY_STATE}`; +const mcpEventIdGenerator = () => `mcp-${uuidV4()}`; + +const writeEventLogAndNotify = ( + requestId: string, + data: McpEventWithoutBase, + { + clearRequestIdMap = false, + newLine = true, + }: { + clearRequestIdMap?: boolean; + newLine?: boolean; + } = {}, +) => { + const eventData: McpEvent = { + ...data, + _id: mcpEventIdGenerator(), + requestId, + timestamp: Date.now(), + }; + const stringifiedData = JSON.stringify(eventData); + const dataToWrite = newLine ? stringifiedData + '\n' : stringifiedData; + eventLogFileStreams.get(requestId)?.write(dataToWrite, () => { + // notify all renderers of new event has been received + for (const window of BrowserWindow.getAllWindows()) { + const resId = requestIdToResponseIdMap.get(requestId); + if (resId) { + const notifyChannel = `${protocol}.${resId}.${REALTIME_EVENTS_CHANNELS.NEW_EVENT}`; + notifyChannel && window.webContents.send(notifyChannel); + if (clearRequestIdMap) { + // clean up maps after last event has been written to file + requestIdToResponseIdMap.delete(requestId); + } + } + } + }); +}; + +const _getMcpClient = (id: string) => { + const mcpClient = mcpConnections.get(id); + invariant( + mcpClient, + `No existing MCP client connection found for requestId: ${id}. It might have been disconnected.`, + ); + return mcpClient; +}; + +const _notifyMcpClientStateChange = (channel: string, isConnected: boolean) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(channel, isConnected); + } +}; + +const _clearMcpMaps = (requestId: string, timelineMessage: string, event?: McpEventWithoutBase) => { + if (event) { + writeEventLogAndNotify(requestId, event, { + clearRequestIdMap: true, + }); + } + 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); +}; + +const _handleCloseMcpConnection = (requestId: string) => { + const closeEvent: McpCloseEventWithoutBase = { + type: 'close', + 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 _handleMcpClientError = (requestId: string, error: Error, prefix?: string) => { + const messageEvent: McpErrorEventWithoutBase = { + type: 'error', + message: prefix || 'Unknown error', + error: error.message, + }; + writeEventLogAndNotify(requestId, messageEvent); + console.error(`MCP client error for ${requestId}`, error); +}; + +const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => { + let messageEvent: McpMessageEventWithoutBase | McpErrorEventWithoutBase | McpNotificationEventWithoutBase; + if (JSONRPCErrorSchema.safeParse(message).success) { + // Error message + const errorDetail = JSONRPCErrorSchema.parse(message).error; + let errorMessage = errorDetail.message; + try { + // Try to parse error message to JSON if possible + errorMessage = JSON.parse(errorMessage); + } catch (error) {} + + messageEvent = { + type: 'error', + error: errorMessage, + message: `MCP Error ${errorDetail.code}`, + }; + } else if (ServerNotificationSchema.safeParse(message).success) { + // Server notification message + messageEvent = { + type: 'notification', + direction: 'INCOMING', + method: getMcpMethodFromMessage(message), + data: ServerNotificationSchema.parse(message), + }; + } else { + if ('result' in message && EmptyResultSchema.safeParse(message.result).success) { + console.info('Ignoring empty result message'); + // ignore empty result message + return; + } + const method = getMcpMethodFromMessage(message); + messageEvent = { + type: 'message', + method, + data: message as JSONRPCResponse, + direction: 'INCOMING', + }; + } + writeEventLogAndNotify(requestId, messageEvent); +}; + +const parseAndLogMcpRequest = (requestId: string, message: any) => { + if (message) { + // Add request event + let requestMethod = message?.method; + if (!requestMethod) { + if (ListRootsResultSchema.safeParse(message?.result).success) { + requestMethod = METHOD_LIST_ROOTS; + } else if (ElicitResultSchema.safeParse(message?.result).success) { + requestMethod = METHOD_ELICITATION_CREATE_MESSAGE; + } else if (JSONRPCErrorSchema.safeParse(message).success) { + requestMethod = 'JSON-RPC Error'; + } else { + requestMethod = METHOD_UNKNOWN; + } + } + const requestEvent: McpRequestEventWithoutBase = { + method: requestMethod, + type: 'message', + direction: 'OUTGOING', + data: message, + }; + writeEventLogAndNotify(requestId, requestEvent); + } +}; + +const createErrorResponse = async ({ + requestId, + responseId, + environmentId, + timelinePath, + message, + transportType, +}: Omit & { message: string; transportType: TransportType }) => { + const settings = await models.settings.get(); + const responsePatch = { + _id: responseId, + parentId: requestId, + environmentId: environmentId, + timelinePath, + statusMessage: 'Error', + error: message, + transportType, + }; + + const res = await models.mcpResponse.updateOrCreate(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 wrapped fetch to log request and response details +const fetchWithLogging = async ( + url: string | URL, + init: RequestInit, + options: ResponseEventOptions, + calledByAuth?: boolean, +) => { + const { requestId, responseId, environmentId, timelinePath, eventLogPath, authProvider } = options; + const { method = 'GET' } = init; + + const reqHeader = new Headers(init?.headers || {}); + const tokens = await authProvider.tokens(); + if (tokens) { + // Keep the same header case as the mcp-ts-sdk: https://github.com/modelcontextprotocol/typescript-sdk/blob/1d475bb3f75674a46d81dba881ea743a763cbc12/src/client/streamableHttp.ts#L175-L178 + reqHeader.set('Authorization', `Bearer ${tokens.access_token}`); + init.headers = reqHeader; + } + + 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'); + // Log outgoing request + parseAndLogMcpRequest(requestId, requestBody); + + 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 = { + _id: responseId, + parentId: requestId, + environmentId, + headers: responseHeaders, + url: url.toString(), + statusCode, + statusMessage, + elapsedTime: performance.now() - start, + timelinePath, + eventLogPath, + transportType: TRANSPORT_TYPES.HTTP, + }; + const settings = await models.settings.get(); + const res = await models.mcpResponse.updateOrCreate(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); + } + + // Avoid infinite loop, only call auth flow once per request + // DELETE method is used to terminate the MCP request, it should not trigger auth flow to keep consistent with the SDK behavior. + // See: https://github.com/modelcontextprotocol/typescript-sdk/blob/058b87c163996b31d5cda744085ecf3c13c5c56a/src/client/streamableHttp.ts#L529-L537 + if (!calledByAuth && statusCode === 401 && method !== 'DELETE') { + const resourceMetadataUrl = extractResourceMetadataUrl(response); + if (resourceMetadataUrl) { + authProvider.saveResourceMetadataUrl(resourceMetadataUrl); + } + + const authEvent: McpAuthEventWithoutBase = { + type: 'message', + method: 'MCP Auth', + direction: 'INCOMING', + data: { + statusCode, + statusMessage, + resourceMetadataUrl: resourceMetadataUrl?.toString() || null, + }, + }; + writeEventLogAndNotify(requestId, authEvent); + + let authResult: AuthResult; + + let authPromiseResolve: (authorizationCode: string) => void = () => {}; + const redirectPromise = new Promise(res => (authPromiseResolve = res)); + const unsubscribe = authProvider.onRedirectEnd(async (authorizationCode: string) => { + // Resolve the promise to continue the auth flow after user has completed authorization in default browser + authPromiseResolve(authorizationCode); + }); + + const authFetchFn = async (url: string | URL, init?: RequestInit) => { + const authRequestEvent: McpAuthEventWithoutBase = { + type: 'message', + method: 'MCP Auth', + direction: 'OUTGOING', + data: { + url: typeof url === 'string' ? url : url.toString(), + method: init?.method || 'GET', + headers: init?.headers || {}, + body: init?.body || null, + }, + }; + writeEventLogAndNotify(requestId, authRequestEvent); + const response = await fetch(url, init); + + const authResponseEvent: McpAuthEventWithoutBase = { + type: 'message', + method: 'MCP Auth', + direction: 'INCOMING', + data: { + statusCode: response.status, + statusMessage: response.statusText, + body: await response.clone().text(), + }, + }; + writeEventLogAndNotify(requestId, authResponseEvent); + + return response; + }; + + try { + // Start auth flow + authResult = await auth(authProvider, { + serverUrl: url, + resourceMetadataUrl, + fetchFn: authFetchFn, + }); + if (authResult === 'REDIRECT') { + // Wait for oauth authorization flow to complete in default browser + const authorizationCode = await redirectPromise; + // Exchange authorization code for tokens + authResult = await auth(authProvider, { + serverUrl: url, + resourceMetadataUrl, + authorizationCode, + fetchFn: authFetchFn, + }); + } + if (authResult !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + return await fetchWithLogging(url, init, options, calledByAuth); + } finally { + unsubscribe(); + } + } + + return response; +}; + +class McpOAuthClientProvider implements OAuthClientProvider { + private _codeVerifier?: string; + private _resourceMetadataUrl?: URL; + private _redirectEndListener: ((authorizationCode: string) => void) | null = null; + constructor(private mcpRequest: McpRequest) {} + get redirectUrl() { + return getOauthRedirectUrl(); + } + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: 'Insomnia MCP Client', + client_uri: 'https://github.com/Kong/insomnia', + scope: 'scope' in this.mcpRequest.authentication ? this.mcpRequest.authentication.scope : undefined, + }; + } + private async refreshMcpRequest() { + const _mcpRequest = await models.mcpRequest.getById(this.mcpRequest._id); + invariant(_mcpRequest, 'MCP Request not found'); + this.mcpRequest = _mcpRequest; + } + private isUsingMcpAuthFlow() { + const { authentication } = this.mcpRequest; + return 'grantType' in authentication && authentication.grantType === 'mcp-auth-flow' && !authentication.disabled; + } + private async updateAuthentication(auth: Partial) { + await models.mcpRequest.update(this.mcpRequest, { + authentication: { + ...this.mcpRequest.authentication, + ...auth, + }, + }); + await this.refreshMcpRequest(); + } + // It's called when auth tries to get client information for authorization, use as a starting point for MCP Auth Flow + // See: https://github.com/modelcontextprotocol/typescript-sdk/blob/1d475bb3f75674a46d81dba881ea743a763cbc12/src/client/auth.ts#L349 + async clientInformation() { + // If not using MCP Auth Flow, wait for user to confirm in the app UI + if (!this.isUsingMcpAuthFlow()) { + BrowserWindow.getAllWindows().forEach(window => { + window.webContents.send('mcp-auth-confirmation'); + }); + await new Promise((resolve, reject) => { + ipcMain.once('mcp.authConfirmed', async (_, confirmed: boolean) => { + if (!confirmed) { + reject(new Error('MCP authorization cancelled by user')); + } else { + await this.updateAuthentication({ + type: 'oauth2', + grantType: 'mcp-auth-flow', + disabled: false, + }); + resolve(); + } + }); + }); + } + + if ('clientId' in this.mcpRequest.authentication && this.mcpRequest.authentication.clientId) { + return { + client_id: this.mcpRequest.authentication.clientId, + client_secret: this.mcpRequest.authentication.clientSecret, + client_id_issued_at: this.mcpRequest.authentication.clientIdIssuedAt, + client_secret_expires_at: this.mcpRequest.authentication.clientSecretExpiresAt, + }; + } + return undefined; + } + async saveClientInformation(clientInformation: OAuthClientInformationFull) { + const parsedClientInformation = OAuthClientInformationSchema.parse(clientInformation); + await models.mcpRequest.update(this.mcpRequest, { + authentication: { + ...this.mcpRequest.authentication, + clientId: parsedClientInformation.client_id, + clientSecret: parsedClientInformation.client_secret, + clientIdIssuedAt: parsedClientInformation.client_id_issued_at, + clientSecretExpiresAt: parsedClientInformation.client_secret_expires_at, + }, + }); + await this.refreshMcpRequest(); + } + async tokens(): Promise { + const { authentication } = this.mcpRequest; + // Don't return tokens if not using MCP Auth Flow or if disabled + if (this.isUsingMcpAuthFlow()) { + const token = await models.oAuth2Token.getOrCreateByParentId(this.mcpRequest._id); + if (token.accessToken) { + return { + access_token: token.accessToken, + refresh_token: token.refreshToken, + id_token: token.identityToken, + expires_in: token.expiresAt ? Math.floor(token.expiresAt / 1000) : undefined, + token_type: ('tokenPrefix' in authentication && authentication.tokenPrefix) || 'Bearer', + }; + } + } + return undefined; + } + async saveTokens(tokens: OAuthTokens) { + const token = await models.oAuth2Token.getOrCreateByParentId(this.mcpRequest._id); + await models.oAuth2Token.update(token, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || '', + identityToken: tokens.id_token || '', + expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null, + }); + await models.mcpRequest.update(this.mcpRequest, { + authentication: { + ...this.mcpRequest.authentication, + scope: tokens.scope, + tokenPrefix: tokens.token_type, + }, + }); + await this.refreshMcpRequest(); + } + saveResourceMetadataUrl(url: URL | undefined) { + this._resourceMetadataUrl = url; + } + get resourceMetadataUrl() { + return this._resourceMetadataUrl; + } + async redirectToAuthorization(authorizationUrl: URL) { + BrowserWindow.getAllWindows().forEach(window => { + window.webContents.send('show-oauth-authorization-modal', authorizationUrl.toString()); + }); + const redirectedTo = await authorizeUserInDefaultBrowser({ + url: authorizationUrl.toString(), + }); + BrowserWindow.getAllWindows().forEach(window => { + window.webContents.send('hide-oauth-authorization-modal', authorizationUrl.toString()); + }); + const redirectParams = Object.fromEntries(new URL(redirectedTo).searchParams); + const authorizationCode = redirectParams.code; + if (!authorizationCode) { + throw new Error('Authorization code not found'); + } + await this._redirectEndListener?.(authorizationCode); + } + onRedirectEnd(listener: (authorizationCode: string) => void) { + this._redirectEndListener = listener; + return () => { + this._redirectEndListener = null; + }; + } + async saveCodeVerifier(codeVerifier: string) { + this._codeVerifier = codeVerifier; + } + async codeVerifier() { + if (!this._codeVerifier) { + throw new Error('Code verifier not set'); + } + return this._codeVerifier; + } +} + +const createStreamableHTTPTransport = async ( + options: OpenMcpHTTPClientConnectionOptions, + { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + authProvider, + }: { + responseId: string; + responseEnvironmentId: string | null; + timelinePath: string; + eventLogPath: string; + authProvider: McpOAuthClientProvider; + }, +) => { + const { url, requestId } = options; + if (!url) { + throw new Error('MCP server url is required'); + } + + const reduceArrayToLowerCaseKeyedDictionary = (acc: Record, { name, value }: RequestHeader) => ({ + ...acc, + [name.toLowerCase() || '']: value || '', + }); + const lowerCasedEnabledHeaders = options.headers + .filter(({ name, disabled }) => Boolean(name) && !disabled) + .reduce(reduceArrayToLowerCaseKeyedDictionary, {}); + + const mcpServerUrl = new URL(url); + const transport = new StreamableHTTPClientTransport(mcpServerUrl, { + requestInit: { + headers: lowerCasedEnabledHeaders, + }, + fetch: (url, init) => + fetchWithLogging(url, init || {}, { + requestId, + responseId, + environmentId: responseEnvironmentId, + timelinePath, + eventLogPath, + authProvider, + }), + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }); + transport.onmessage = message => _handleMcpMessage(message, requestId); + return transport; +}; + +const createStdioTransport = ( + options: OpenMcpStdioClientConnectionOptions, + { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }: { + responseId: string; + responseEnvironmentId: string | null; + timelinePath: string; + eventLogPath: string; + }, +) => { + const { url, requestId, env } = options; + if (!url) { + throw new Error('Command is required for STDIO transport'); + } + const parseResult = parse(url); + if (parseResult.find(arg => typeof arg !== 'string')) { + throw new Error('Invalid command format'); + } + const [command, ...args] = parseResult as string[]; + + const initialTimelines = getInitialTimeline(`STDIO: ${url}`); + // Add stdio-specific timeline info + initialTimelines.push({ + value: `Run command: ${url}`, + name: 'HeaderOut', + timestamp: Date.now(), + }); + const stringifiedEnv = Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join(' ') + .trim(); + if (stringifiedEnv) { + initialTimelines.push({ + value: `With env: ${stringifiedEnv}`, + name: 'HeaderOut', + timestamp: Date.now(), + }); + } + initialTimelines.map(t => timelineFileStreams.get(requestId)?.write(JSON.stringify(t) + '\n')); + + const start = performance.now(); + const transport = new StdioClientTransport({ + command, + args, + env: { + ...getDefaultEnvironment(), + ...env, + }, + stderr: 'pipe', + }); + + // Capture stderr logs for debugging + const stderrStream = transport.stderr; + stderrStream?.on('data', (chunk: Buffer) => { + const stderrData = chunk.toString().trim(); + if (!stderrData) return; // Skip empty lines + + // Log stderr output to timeline with appropriate categorization + timelineFileStreams.get(requestId)?.write( + JSON.stringify({ + value: stderrData, + name: 'HeaderIn', + timestamp: Date.now(), + }) + '\n', + ); + }); + + // Wrap the original send method to log outgoing requests for stdio transport + const originalSend = transport.send.bind(transport); + transport.send = async (message: JSONRPCRequest) => { + const isInitializedMessage = InitializeRequestSchema.safeParse(message).success; + // Create response model for initialize message and add process status timeline + if (isInitializedMessage) { + // Add process started timeline (similar to HTTP response timeline) + timelineFileStreams + .get(requestId) + ?.write(JSON.stringify({ value: 'Process started and ready', name: 'Text', timestamp: Date.now() }) + '\n'); + + const responsePatch: Partial = { + _id: responseId, + parentId: requestId, + environmentId: responseEnvironmentId, + url, + elapsedTime: performance.now() - start, + timelinePath, + eventLogPath, + transportType: TRANSPORT_TYPES.STDIO, + }; + const settings = await models.settings.get(); + const res = await models.mcpResponse.updateOrCreate(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); + } + // Log outgoing request + parseAndLogMcpRequest(requestId, message); + + return originalSend(message); + }; + + transport.onmessage = message => _handleMcpMessage(message, requestId); + return transport; +}; + +const createTransportAndConnect = async ( + mcpClient: Client, + connectionOptions: OpenMcpClientConnectionOptions, + options: { + responseId: string; + responseEnvironmentId: string | null; + timelinePath: string; + eventLogPath: string; + }, +) => { + if (!isOpenMcpHTTPClientConnectionOptions(connectionOptions)) { + const transport = await createStdioTransport(connectionOptions, options); + await mcpClient.connect(transport); + } else { + const mcpRequest = await models.mcpRequest.getById(connectionOptions.requestId); + invariant(mcpRequest, 'MCP Request not found'); + + const authProvider = new McpOAuthClientProvider(mcpRequest); + const transport = await createStreamableHTTPTransport(connectionOptions, { + ...options, + authProvider, + }); + // Use a longer timeout for initial connection to allow for auth flow to complete + await mcpClient.connect(transport, { timeout: 3 * 60 * 1000 }); + } + const mcpRequest = await models.mcpRequest.getById(connectionOptions.requestId); + invariant(mcpRequest, 'MCP Request not found'); + + let authType = 'none'; + if ('type' in mcpRequest.authentication) { + if (mcpRequest.authentication.type === 'oauth2') { + authType = 'oauth2-' + mcpRequest.authentication.grantType; + } else { + authType = mcpRequest.authentication.type; + } + } + const authDisabled = 'disabled' in mcpRequest.authentication && mcpRequest.authentication.disabled; + const isFirstConnection = !mcpRequest.connected; + trackSegmentEvent(SegmentEvent.mcpClientConnected, { + transportType: connectionOptions.transportType, + firstTime: isFirstConnection, + ...(connectionOptions.transportType === TRANSPORT_TYPES.HTTP + ? { + authType, + authDisabled, + } + : {}), + }); + if (isFirstConnection) { + // Mark as connected for the first time + await models.mcpRequest.update(mcpRequest, { connected: true }); + } +}; + +const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) => { + const { requestId, workspaceId } = options; + + // create response model and file streams + const responseId = generateId(mcpResponsePrefix); + 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)); + requestIdToResponseIdMap.set(options.requestId, responseId); + + 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 MCP paylod model if not exists + await models.mcpPayload.getOrCreateByParentIdAndUrl(requestId, options.url); + + // create connection + const mcpClient = new Client( + { + name: getProductName(), + version: getAppVersion(), + }, + { + capabilities: { + roots: { + // declare the client to support list roots + listChanged: true, + }, + }, + }, + ); + mcpClient.onerror = _error => _handleMcpClientError(requestId, _error, 'MCP Client Error'); + const mcpStateChannel = getMcpStateChannel(requestId); + + try { + await createTransportAndConnect(mcpClient, options, { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }); + mcpClient.onclose = () => _handleCloseMcpConnection(requestId); + } catch (error) { + // Log error when connection fails with exception + createErrorResponse({ + requestId, + responseId, + environmentId: responseEnvironmentId, + timelinePath, + eventLogPath, + message: error.message || 'Something went wrong', + transportType: options.transportType, + }); + console.error(`Failed to create ${options.transportType} transport: ${error}`); + _handleCloseMcpConnection(requestId); + return; + } + // Support listing roots + mcpClient.setRequestHandler(ListRootsRequestSchema, async () => { + const mcpRequest = await models.mcpRequest.getById(requestId); + invariant(mcpRequest, 'MCP request not found'); + return { roots: mcpRequest.roots }; + }); + + if (mcpConnections.has(requestId)) { + // close existing connection if any, avoid multiple connections with same requestId + await closeMcpConnection({ requestId }); + } + mcpConnections.set(requestId, mcpClient as McpClient); + const serverCapabilities = mcpClient.getServerCapabilities(); + const primitivePromises: Promise[] = []; + // get server primitives if supported + if (serverCapabilities?.tools) { + primitivePromises.push(mcpClient.listTools()); + } + if (serverCapabilities?.resources) { + primitivePromises.push(mcpClient.listResources()); + primitivePromises.push(mcpClient.listResourceTemplates()); + } + if (serverCapabilities?.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); + } + // notify connection ready after capabilities and primitives are fetched + _notifyMcpClientStateChange(mcpStateChannel, true); +}; + +const closeMcpConnection = async (options: CommonMcpOptions) => { + const { requestId } = options; + const mcpClient = _getMcpClient(requestId); + if (mcpClient) { + try { + // Only terminate session if transport is StreamableHTTPClientTransport + if ('terminateSession' in mcpClient.transport) { + await mcpClient.transport.terminateSession(); + } + } catch (err) { + _handleMcpClientError(requestId, err as Error, 'Failed to terminate MCP session'); + } finally { + // Alway close the connection even the transport terminate session fails + // This occurs when the server is not reachable, terminateSession failure will cause the connection to never close + mcpClient.close(); + // Execute clear resource subscription in main process rather than UI to make sure closeAllMcpConnections method will clear subscriptions + await models.mcpRequest.clearResourceSubscriptions(requestId); + } + trackSegmentEvent(SegmentEvent.mcpClientDisconnected); + } +}; + +const closeAllMcpConnections = () => { + for (const [requestId] of mcpConnections) { + closeMcpConnection({ requestId }); + } +}; + +const findMany = async (options: { responseId: string }): Promise => { + 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) => { + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const tools = await mcpClient.listTools(); + return tools; + } + return null; +}; + +const callTool = async (options: CallToolOptions) => { + const { requestId, name, parameters = {} } = options; + const mcpClient = _getMcpClient(requestId); + if (mcpClient) { + const response = await mcpClient.callTool({ name, arguments: parameters }, CompatibilityCallToolResultSchema); + trackSegmentEvent(SegmentEvent.mcpToolCalled); + return response.content; + } + return null; +}; + +const listPrompts = async (options: CommonMcpOptions & ListPromptsRequest['params']) => { + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const prompts = await mcpClient.listPrompts(); + return prompts; + } + return null; +}; + +const getPrompt = async (options: CommonMcpOptions & GetPromptRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const prompt = await mcpClient.getPrompt(params); + trackSegmentEvent(SegmentEvent.mcpPromptCalled); + return prompt; + } + return null; +}; + +const listResources = async (options: CommonMcpOptions & ListResourcesRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const resources = await mcpClient.listResources(params); + return resources; + } + return null; +}; + +const subscribeResource = async (options: CommonMcpOptions & SubscribeRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const result = await mcpClient.subscribeResource(params); + // Subscribe resource do not have a formal response schema, so we log it manually + const messageEvent: Omit & { data: {} } = { + type: 'message', + method: METHOD_SUBSCRIBE_RESOURCE, + data: result, + direction: 'INCOMING', + }; + writeEventLogAndNotify(requestId, messageEvent); + return result; + } + return null; +}; + +const unsubscribeResource = async (options: CommonMcpOptions & UnsubscribeRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const result = await mcpClient.unsubscribeResource(params); + // Unsubscribe resource do not have a formal response schema, so we log it manually + const messageEvent: Omit & { data: {} } = { + type: 'message', + method: METHOD_UNSUBSCRIBE_RESOURCE, + data: result, + direction: 'INCOMING', + }; + writeEventLogAndNotify(requestId, messageEvent); + return result; + } + return null; +}; + +const listResourceTemplates = async (options: CommonMcpOptions & ListResourcesRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(requestId); + if (mcpClient) { + const resourceTemplates = await mcpClient.listResourceTemplates(params); + return resourceTemplates; + } + return null; +}; + +const getMcpReadyState = async (options: CommonMcpOptions) => { + try { + const mcpClient = _getMcpClient(options.requestId); + // if no mcp client, it means it's disconnected + return !!mcpClient; + } catch (error) { + return false; + } +}; + +const readResource = async (options: CommonMcpOptions & ReadResourceRequest['params']) => { + const { requestId, ...params } = options; + const mcpClient = _getMcpClient(requestId); + if (mcpClient) { + const resource = await mcpClient.readResource(params); + trackSegmentEvent(SegmentEvent.mcpResourceRead); + return resource; + } + return null; +}; + +const sendRootListChangeNotification = async (options: CommonMcpOptions) => { + const mcpClient = _getMcpClient(options.requestId); + if (mcpClient) { + const result = await mcpClient.sendRootsListChanged(); + return result; + } + return null; +}; + +export interface McpBridgeAPI { + connect: typeof openMcpClientConnection; + close: typeof closeMcpConnection; + closeAll: typeof closeAllMcpConnections; + authConfirmation: (confirmed: boolean) => void; + primitive: { + listTools: typeof listTools; + callTool: typeof callTool; + listPrompts: typeof listPrompts; + getPrompt: typeof getPrompt; + listResources: typeof listResources; + listResourceTemplates: typeof listResourceTemplates; + readResource: typeof readResource; + subscribeResource: typeof subscribeResource; + unsubscribeResource: typeof unsubscribeResource; + }; + readyState: { + getCurrent: typeof getMcpReadyState; + }; + notification: { + rootListChange: typeof sendRootListChangeNotification; + }; + event: { + findMany: typeof findMany; + }; +} + +export const registerMcpHandlers = () => { + ipcMainHandle('mcp.connect', (_, options: Parameters[0]) => + openMcpClientConnection(options), + ); + ipcMainHandle('mcp.primitive.listTools', (_, options: Parameters[0]) => listTools(options)); + ipcMainHandle('mcp.primitive.callTool', (_, options: Parameters[0]) => callTool(options)); + ipcMainHandle('mcp.primitive.listPrompts', (_, options: Parameters[0]) => listPrompts(options)); + ipcMainHandle('mcp.primitive.getPrompt', (_, options: Parameters[0]) => getPrompt(options)); + ipcMainHandle('mcp.primitive.listResources', (_, options: Parameters[0]) => + listResources(options), + ); + ipcMainHandle('mcp.primitive.listResourceTemplates', (_, options: Parameters[0]) => + listResourceTemplates(options), + ); + ipcMainHandle('mcp.primitive.readResource', (_, options: Parameters[0]) => + readResource(options), + ); + ipcMainHandle('mcp.primitive.subscribeResource', (_, options: Parameters[0]) => + subscribeResource(options), + ); + ipcMainHandle('mcp.primitive.unsubscribeResource', (_, options: Parameters[0]) => + unsubscribeResource(options), + ); + ipcMainHandle('mcp.close', (_, options: Parameters[0]) => closeMcpConnection(options)); + ipcMainOn('mcp.closeAll', closeAllMcpConnections); + ipcMainHandle('mcp.readyState', (_, options: Parameters[0]) => getMcpReadyState(options)); + ipcMainHandle('mcp.event.findMany', (_, options: Parameters[0]) => findMany(options)); + ipcMainHandle('mcp.notification.rootListChange', (_, options: Parameters[0]) => + sendRootListChangeNotification(options), + ); +}; + +electron.app.on('window-all-closed', closeAllMcpConnections); diff --git a/packages/insomnia/src/main/network/socket-io.ts b/packages/insomnia/src/main/network/socket-io.ts index 579f7ee62a2..31726a5d0b0 100644 --- a/packages/insomnia/src/main/network/socket-io.ts +++ b/packages/insomnia/src/main/network/socket-io.ts @@ -8,6 +8,8 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import { io as SocketIOClient, type ManagerOptions, type Socket, type SocketOptions } from 'socket.io-client'; import { v4 as uuidV4 } from 'uuid'; +import { REALTIME_EVENTS_CHANNELS } from '~/common/constants'; + import { jarFromCookies } from '../../common/cookies'; import { generateId } from '../../common/misc'; import * as models from '../../models'; @@ -88,7 +90,8 @@ const eventLogFileStreams = new Map(); const timelineFileStreams = new Map(); const protocolName = 'socketIO'; -const getEventNotificationChannel = (responseId: string) => `${protocolName}.${responseId}.newEventReceived`; +const getEventNotificationChannel = (responseId: string) => + `${protocolName}.${responseId}.${REALTIME_EVENTS_CHANNELS.NEW_EVENT}`; const writeEventLogAndNotify = ({ requestId, @@ -261,7 +264,7 @@ const openSocketIOConnection = async ( if (!options.url) { throw new Error('URL is required'); } - const readyStateChannel = `socketIO.${request._id}.readyState`; + const readyStateChannel = `socketIO.${request._id}.${REALTIME_EVENTS_CHANNELS.READY_STATE}`; const reduceArrayToLowerCaseKeyedDictionary = ( acc: Record, diff --git a/packages/insomnia/src/main/network/websocket.ts b/packages/insomnia/src/main/network/websocket.ts index ace586c7d83..ca6ba33c13e 100644 --- a/packages/insomnia/src/main/network/websocket.ts +++ b/packages/insomnia/src/main/network/websocket.ts @@ -10,6 +10,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import { v4 as uuidV4 } from 'uuid'; import { type CloseEvent, type ErrorEvent, type Event, type MessageEvent, WebSocket } from 'ws'; +import { REALTIME_EVENTS_CHANNELS } from '~/common/constants'; import { database } from '~/common/database'; import { jarFromCookies } from '../../common/cookies'; @@ -78,7 +79,8 @@ const requestIdToResponseIdMap = new Map(); const eventLogFileStreams = new Map(); const timelineFileStreams = new Map(); -const getEventNotificationChannel = (responseId: string) => `${protocolName}.${responseId}.newEventReceived`; +const getEventNotificationChannel = (responseId: string) => + `${protocolName}.${responseId}.${REALTIME_EVENTS_CHANNELS.NEW_EVENT}`; const writeEventLogAndNotify = ({ requestId, @@ -180,7 +182,7 @@ const openWebSocketConnection = async ( if (!options.url) { throw new Error('URL is required'); } - const readyStateChannel = `${protocolName}.${request._id}.readyState`; + const readyStateChannel = `${protocolName}.${request._id}.${REALTIME_EVENTS_CHANNELS.READY_STATE}`; const reduceArrayToLowerCaseKeyedDictionary = ( acc: Record, diff --git a/packages/insomnia/src/models/helpers/project.ts b/packages/insomnia/src/models/helpers/project.ts index 53031b9f711..cb24da03eb5 100644 --- a/packages/insomnia/src/models/helpers/project.ts +++ b/packages/insomnia/src/models/helpers/project.ts @@ -7,7 +7,7 @@ import type { VCS } from '../../sync/vcs/vcs'; import { insomniaFetch } from '../../ui/insomniaFetch'; import { invariant } from '../../utils/invariant'; import { isDefaultOrganizationProject, type Project, update as updateProject } from '../project'; -import type { Workspace } from '../workspace'; +import { isMcp, type Workspace } from '../workspace'; import { getOrCreateByParentId as getOrCreateWorkspaceMeta } from '../workspace-meta'; export const sortProjects = (projects: Project[]) => [ ...projects.filter(p => isDefaultOrganizationProject(p)).sort((a, b) => a.name.localeCompare(b.name)), @@ -62,6 +62,9 @@ export async function updateLocalProjectToRemote({ }); for (const workspace of projectWorkspaces) { + if (isMcp(workspace)) { + continue; + } const workspaceMeta = await getOrCreateWorkspaceMeta(workspace._id); // Initialize Sync on the workspace if it's not using Git sync diff --git a/packages/insomnia/src/models/helpers/request-operations.ts b/packages/insomnia/src/models/helpers/request-operations.ts index 25c8e6ec8ef..10c3a9dca80 100644 --- a/packages/insomnia/src/models/helpers/request-operations.ts +++ b/packages/insomnia/src/models/helpers/request-operations.ts @@ -1,12 +1,13 @@ import { type GrpcRequest, isGrpcRequest, isGrpcRequestId } from '../grpc-request'; import * as models from '../index'; +import { isMcpRequest, isMcpRequestId, type McpRequest } from '../mcp-request'; import type { Request } from '../request'; import { isSocketIORequest, isSocketIORequestId, type SocketIORequest } from '../socket-io-request'; import { isWebSocketRequest, isWebSocketRequestId, type WebSocketRequest } from '../websocket-request'; export function getById( requestId: string, -): Promise { +): Promise { if (isGrpcRequestId(requestId)) { return models.grpcRequest.getById(requestId); } @@ -17,10 +18,14 @@ export function getById( if (isSocketIORequestId(requestId)) { return models.socketIORequest.getById(requestId); } + + if (isMcpRequestId(requestId)) { + return models.mcpRequest.getById(requestId); + } return models.request.getById(requestId); } -export function remove(request: Request | GrpcRequest | WebSocketRequest | SocketIORequest) { +export function remove(request: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest) { if (isGrpcRequest(request)) { return models.grpcRequest.remove(request); } @@ -31,6 +36,11 @@ export function remove(request: Request | GrpcRequest | WebSocketRequest | Socke if (isSocketIORequest(request)) { return models.socketIORequest.remove(request); } + + if (isMcpRequest(request)) { + return models.mcpRequest.remove(request); + } + return models.request.remove(request); } @@ -50,6 +60,13 @@ export function update(request: T, patch: Partial = {}): Pr // @ts-expect-error -- TSCONVERSION return models.socketIORequest.update(request, patch); } + + // @ts-expect-error -- TSCONVERSION + if (isMcpRequest(request)) { + // @ts-expect-error -- TSCONVERSION + return models.mcpRequest.update(request, patch); + } + // @ts-expect-error -- TSCONVERSION return models.request.update(request, patch); } diff --git a/packages/insomnia/src/models/index.ts b/packages/insomnia/src/models/index.ts index fdbbbe4e3f7..7503f92b6d7 100644 --- a/packages/insomnia/src/models/index.ts +++ b/packages/insomnia/src/models/index.ts @@ -10,6 +10,9 @@ import * as _gitCredentials from './git-credentials'; import * as _gitRepository from './git-repository'; import * as _grpcRequest from './grpc-request'; import * as _grpcRequestMeta from './grpc-request-meta'; +import * as _mcpRequest from './mcp-request'; +import * as _mcpPayload from './mcp-request-payload'; +import * as _mcpResponse from './mcp-response'; import * as _mockRoute from './mock-route'; import * as _mockServer from './mock-server'; import * as _oAuth2Token from './o-auth-2-token'; @@ -92,6 +95,9 @@ export const workspaceMeta = _workspaceMeta; export * as organization from './organization'; export const userSession = _userSession; export const cloudCredential = _cloudCredential; +export const mcpRequest = _mcpRequest; +export const mcpResponse = _mcpResponse; +export const mcpPayload = _mcpPayload; export function all() { // NOTE: This list should be from most to least specific (ie. parents above children) @@ -136,6 +142,9 @@ export function all() { socketIOPayload, socketIOResponse, cloudCredential, + mcpRequest, + mcpResponse, + mcpPayload, ] as const; } export function types() { @@ -178,6 +187,9 @@ export type AllTypes = | 'WebSocketPayload' | 'WebSocketRequest' | 'WebSocketResponse' + | 'McpRequest' + | 'McpResponse' + | 'McpPayload' | 'Workspace' | 'WorkspaceMeta'; @@ -282,6 +294,7 @@ export const getAllDescendantMap = (): Partial> => runnerTestResult.type, caCertificate.type, clientCertificate.type, + mcpRequest.type, ], [requestGroup.type]: [ requestGroup.type, @@ -297,6 +310,7 @@ export const getAllDescendantMap = (): Partial> => [grpcRequest.type]: [grpcRequestMeta.type], [webSocketRequest.type]: [webSocketPayload.type, webSocketResponse.type, requestMeta.type], [socketIORequest.type]: [socketIOPayload.type, socketIOResponse.type, requestMeta.type], + [mcpRequest.type]: [mcpPayload.type, mcpResponse.type], [mockServer.type]: [mockRoute.type], [environment.type]: [environment.type], [unitTestSuite.type]: [unitTest.type, unitTestResult.type], diff --git a/packages/insomnia/src/models/mcp-request-payload.ts b/packages/insomnia/src/models/mcp-request-payload.ts new file mode 100644 index 00000000000..1f9e30c274d --- /dev/null +++ b/packages/insomnia/src/models/mcp-request-payload.ts @@ -0,0 +1,94 @@ +import { database } from '../common/database'; +import type { BaseModel } from '.'; + +export const name = 'MCP Payload'; + +export const type = 'McpPayload'; + +export const prefix = 'mcp-payload'; + +export const canDuplicate = true; + +export const canSync = false; + +export interface BaseMcpPayload { + params?: Record; + url: string; +} + +export type McpPayload = BaseModel & BaseMcpPayload & { type: typeof type }; + +export const isSocketIOPayload = (model: Pick): model is McpPayload => model.type === type; + +export const isMcpPayloadId = (id: string | null) => id?.startsWith(`${prefix}_`); + +export const init = (): BaseMcpPayload => { + return { + params: {}, + url: '', + }; +}; + +export const migrate = (doc: McpPayload) => doc; + +export const create = (patch: Partial = {}) => { + if (!patch.parentId) { + throw new Error(`New McpPayload missing \`parentId\`: ${JSON.stringify(patch)}`); + } + + return database.docCreate(type, patch); +}; + +export const remove = (obj: McpPayload) => database.remove(obj); + +export const update = (obj: McpPayload, patch: Partial = {}) => database.docUpdate(obj, patch); + +export async function duplicate(request: McpPayload, patch: Partial = {}) { + // Only set name and "(Copy)" if the patch does + // not define it and the request itself has a name. + // Otherwise leave it blank so the request URL can + // fill it in automatically. + if (!patch.name && request.name) { + patch.name = `${request.name} (Copy)`; + } + + return database.duplicate(request, { + name, + ...patch, + }); +} + +export const getById = (_id: string) => database.findOne(type, { _id }); +export const getByParentId = (parentId: string) => database.find(type, { parentId }); + +export const getByParentIdAndUrl = (parentId: string, url: string) => + database.findOne(type, { parentId, url }); + +export async function updateOrCreateByParentIdAndUrl(parentId: string, patch: Partial) { + const requestPayload = await getByParentIdAndUrl(parentId, patch.url || ''); + + if (requestPayload) { + return update(requestPayload, patch); + } + const newPatch = Object.assign( + { + parentId, + }, + patch, + ); + return create(newPatch); +} + +export async function getOrCreateByParentIdAndUrl(parentId: string, url: string) { + const result = await database.findOne(type); + + if (!result) { + return await create({ + parentId, + url, + }); + } + return result; +} + +export const all = () => database.find(type); diff --git a/packages/insomnia/src/models/mcp-request.ts b/packages/insomnia/src/models/mcp-request.ts new file mode 100644 index 00000000000..39822bfbae3 --- /dev/null +++ b/packages/insomnia/src/models/mcp-request.ts @@ -0,0 +1,97 @@ +import type { Root } from '@modelcontextprotocol/sdk/types.js'; + +import { invariant } from '~/utils/invariant'; + +import { database as db } from '../common/database'; +import { type EnvironmentKvPairData } from './environment'; +import type { BaseModel } from './index'; +import type { RequestAuthentication, RequestHeader } from './request'; + +export const name = 'MCP Request'; +export const type = 'McpRequest'; +export const prefix = 'mcp-req'; +export const canDuplicate = true; +export const canSync = false; + +export const TRANSPORT_TYPES = { + STDIO: 'stdio', + HTTP: 'streamable-http', +} as const; +export type TransportType = (typeof TRANSPORT_TYPES)[keyof typeof TRANSPORT_TYPES]; + +export interface BaseMcpRequest { + name: string; + url: string; + transportType: TransportType; + description: string; + headers: RequestHeader[]; + authentication: RequestAuthentication | {}; + env: EnvironmentKvPairData[]; + mcpStdioAccess: boolean; + roots: Root[]; + subscribeResources: string[]; + connected: boolean; +} +export type McpServerPrimitiveTypes = 'tools' | 'resources' | 'prompts' | 'resourceTemplates'; + +export const MCP_TRANSPORT_TYPES: TransportType[] = [TRANSPORT_TYPES.HTTP, TRANSPORT_TYPES.STDIO]; + +export type McpRequest = BaseModel & BaseMcpRequest & { type: typeof type }; + +export const isMcpRequest = (model: Pick): model is McpRequest => model.type === type; + +export const isMcpRequestId = (id?: string | null) => id?.startsWith(`${prefix}_`); + +export function init(): BaseMcpRequest { + return { + url: '', + transportType: TRANSPORT_TYPES.HTTP, + name: 'New MCP Client', + description: '', + headers: [], + authentication: {}, + env: [], + mcpStdioAccess: false, + roots: [], + subscribeResources: [], + connected: false, + }; +} + +export function migrate(doc: McpRequest) { + return doc; +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New GrpcRequest missing `parentId`'); + } + + return db.docCreate(type, patch); +} + +export function remove(obj: McpRequest) { + return db.remove(obj); +} + +export function all() { + return db.find(type); +} + +export function getByParentId(parentId: string) { + return db.findOne(type, { parentId }); +} + +export function getById(id: string) { + return db.findOne(type, { _id: id }); +} + +export function update(request: McpRequest, patch: Partial = {}) { + return db.docUpdate(request, patch); +} + +export async function clearResourceSubscriptions(requestId: string) { + const request = await getById(requestId); + invariant(request, 'McpRequest not found'); + return update(request, { subscribeResources: [] }); +} diff --git a/packages/insomnia/src/models/mcp-response.ts b/packages/insomnia/src/models/mcp-response.ts new file mode 100644 index 00000000000..dddb6ba6f74 --- /dev/null +++ b/packages/insomnia/src/models/mcp-response.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs'; + +import { database as db } from '../common/database'; +import * as requestOperations from './helpers/request-operations'; +import type { BaseModel } from './index'; +import * as models from './index'; +import { TRANSPORT_TYPES, type TransportType } from './mcp-request'; +import type { ResponseHeader } from './response'; + +export const name = 'Mcp Response'; +export const type = 'McpResponse'; +export const prefix = 'mcp-response'; +export const canDuplicate = false; +export const canSync = false; + +export interface BaseMcpResponse { + environmentId: string | null; + statusCode: number; + statusMessage: string; + url: string; + elapsedTime: number; + headers: ResponseHeader[]; + // Event logs are stored on the filesystem + eventLogPath: string; + // Actual timelines are stored on the filesystem + timelinePath: string; + error: string; + requestVersionId: string | null; + transportType: TransportType; +} + +export type McpResponse = BaseModel & BaseMcpResponse; + +export const isMcpResponse = (model: Pick): model is McpResponse => model.type === type; + +export function init(): BaseMcpResponse { + return { + url: '', + elapsedTime: 0, + headers: [], + timelinePath: '', + eventLogPath: '', + error: '', + statusCode: 0, + statusMessage: '', + requestVersionId: null, + environmentId: null, + transportType: TRANSPORT_TYPES.HTTP, + }; +} + +export function migrate(doc: McpResponse) { + return doc; +} + +export function getById(id: string) { + return db.findOne(type, { _id: id }); +} + +export function findByParentId(parentId: string) { + return db.find(type, { parentId: parentId }); +} + +export async function all() { + return db.find(type); +} + +export async function removeForRequest(parentId: string, environmentId?: string | null) { + const settings = await models.settings.get(); + const query: Record = { + parentId, + }; + + // Only add if not undefined. null is not the same as undefined + // null: find responses sent from base environment + // undefined: find all responses + if (environmentId !== undefined && settings.filterResponsesByEnv) { + query.environmentId = environmentId; + } + const toDelete = await db.find(type, query); + for (const doc of toDelete) { + fs.promises.unlink(doc.eventLogPath); + fs.promises.unlink(doc.timelinePath); + } + // Also delete legacy responses here or else the user will be confused as to + // why some responses are still showing in the UI. + await db.removeWhere(type, query); +} + +export function remove(response: McpResponse) { + fs.promises.unlink(response.eventLogPath); + fs.promises.unlink(response.timelinePath); + return db.remove(response); +} + +export async function create(patch: Partial = {}, maxResponses = 20) { + if (!patch.parentId) { + throw new Error('New Response missing `parentId`'); + } + + const { parentId } = patch; + // Create request version snapshot + const request = await requestOperations.getById(parentId); + const requestVersion = request ? await models.requestVersion.create(request) : null; + patch.requestVersionId = requestVersion ? requestVersion._id : null; + // Filter responses by environment if setting is enabled + const query: Record = { + parentId, + }; + + if ((await models.settings.get()).filterResponsesByEnv && 'environmentId' in patch) { + query.environmentId = patch.environmentId; + } + + // Delete all other responses before creating the new one + const responsesToShow = Math.max(1, maxResponses); + + const allResponses = await db.find(type, query, { modified: -1 }, responsesToShow); + + const recentIds = allResponses.map(r => r._id); + // Remove all that were in the last query, except the first `maxResponses` IDs + await db.removeWhere(type, { + ...query, + _id: { + $nin: recentIds, + }, + }); + // Actually create the new response + return db.docCreate(type, patch); +} + +export async function updateOrCreate(patch: Partial, maxResponses = 20) { + const id = patch._id; + if (!id) { + throw new Error('Cannot updateOrCreate McpResponse without _id'); + } + + const existing = await getById(id); + if (existing) { + return db.docUpdate(existing, patch); + } + + return create(patch, maxResponses); +} + +export async function getLatestForRequestId(requestId: string, environmentId: string | null) { + // Filter responses by environment if setting is enabled + + const shouldFilter = (await models.settings.get()).filterResponsesByEnv; + + const response = await db.findOne( + type, + { + parentId: requestId, + ...(shouldFilter ? { environmentId } : {}), + }, + { modified: -1 }, + ); + return response; +} diff --git a/packages/insomnia/src/models/project.ts b/packages/insomnia/src/models/project.ts index 336bbd91737..79c7cf0da46 100644 --- a/packages/insomnia/src/models/project.ts +++ b/packages/insomnia/src/models/project.ts @@ -30,6 +30,7 @@ export const projectHasSettings = (project: Pick) => !isScratchp interface CommonProject { name: string; + mcpStdioAccess?: boolean; } export interface RemoteProject extends BaseModel, CommonProject { @@ -58,6 +59,7 @@ export function init(): Partial { name: 'My Project', remoteId: null, // `null` is necessary for the model init logic to work properly gitRepositoryId: null, + mcpStdioAccess: false, }; } diff --git a/packages/insomnia/src/models/request-meta.ts b/packages/insomnia/src/models/request-meta.ts index 412b0fe6d8f..7e9e968a119 100644 --- a/packages/insomnia/src/models/request-meta.ts +++ b/packages/insomnia/src/models/request-meta.ts @@ -21,6 +21,7 @@ export interface BaseRequestMeta { lastActive: number; downloadPath: string | null; expandedAccordionKeys: Partial>; + activeMcpPrimitive?: string | null; } export type RequestMeta = BaseModel & BaseRequestMeta; @@ -39,6 +40,7 @@ export function init() { lastActive: 0, downloadPath: null, expandedAccordionKeys: {}, + activeMcpPrimitive: null, }; } diff --git a/packages/insomnia/src/models/request-version.ts b/packages/insomnia/src/models/request-version.ts index 4ee276b2419..037f5f43ce5 100644 --- a/packages/insomnia/src/models/request-version.ts +++ b/packages/insomnia/src/models/request-version.ts @@ -5,6 +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 { 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'; @@ -56,8 +57,8 @@ export function findByParentId(parentId: string) { return db.find(type, { parentId }); } -export async function create(request: Request | WebSocketRequest | GrpcRequest | SocketIORequest) { - if (!isRequest(request) && !isWebSocketRequest(request) && !isSocketIORequest(request)) { +export async function create(request: Request | WebSocketRequest | GrpcRequest | SocketIORequest | McpRequest) { + if (!isRequest(request) && !isWebSocketRequest(request) && !isSocketIORequest(request) && !isMcpRequest(request)) { throw new Error(`New ${type} was not given a valid ${request.type} instance`); } @@ -117,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; diff --git a/packages/insomnia/src/models/request.ts b/packages/insomnia/src/models/request.ts index 8e77fb8e68e..19a170cd4f3 100644 --- a/packages/insomnia/src/models/request.ts +++ b/packages/insomnia/src/models/request.ts @@ -34,11 +34,13 @@ export interface AuthTypeAPIKey { export interface AuthTypeOAuth2 { type: 'oauth2'; disabled?: boolean; - grantType: 'authorization_code' | 'client_credentials' | 'password' | 'implicit' | 'refresh_token'; + grantType: 'authorization_code' | 'client_credentials' | 'password' | 'implicit' | 'refresh_token' | 'mcp-auth-flow'; accessTokenUrl?: string; authorizationUrl?: string; clientId?: string; clientSecret?: string; + clientIdIssuedAt?: number; + clientSecretExpiresAt?: number; audience?: string; scope?: string; resource?: string; diff --git a/packages/insomnia/src/models/workspace.ts b/packages/insomnia/src/models/workspace.ts index eb748fe4c37..54901984d1c 100644 --- a/packages/insomnia/src/models/workspace.ts +++ b/packages/insomnia/src/models/workspace.ts @@ -16,7 +16,7 @@ export interface BaseWorkspace { name: string; description: string; certificates?: any; // deprecated - scope: 'design' | 'collection' | 'mock-server' | 'environment'; + scope: 'design' | 'collection' | 'mock-server' | 'environment' | 'mcp'; } export type WorkspaceScope = BaseWorkspace['scope']; @@ -26,6 +26,7 @@ export const WorkspaceScopeKeys = { collection: 'collection', mockServer: 'mock-server', environment: 'environment', + mcp: 'mcp', } as const; export type Workspace = BaseModel & BaseWorkspace; @@ -41,6 +42,8 @@ export const isMockServer = (workspace: Pick) => workspace.s export const isEnvironment = (workspace: Pick) => workspace.scope === WorkspaceScopeKeys.environment; +export const isMcp = (workspace: Pick) => workspace.scope === WorkspaceScopeKeys.mcp; + export const init = (): BaseWorkspace => ({ name: `New ${strings.collection.singular}`, description: '', @@ -76,8 +79,8 @@ export async function all() { return await db.find(type); } -export function count() { - return db.count(type); +export function count(scope?: WorkspaceScope) { + return db.count(type, scope ? { scope } : {}); } export function update(workspace: Workspace, patch: Partial) { @@ -140,7 +143,8 @@ function _migrateScope(workspace: MigrationWorkspace) { workspace.scope === WorkspaceScopeKeys.design || workspace.scope === WorkspaceScopeKeys.collection || workspace.scope === WorkspaceScopeKeys.mockServer || - workspace.scope === WorkspaceScopeKeys.environment + workspace.scope === WorkspaceScopeKeys.environment || + workspace.scope === WorkspaceScopeKeys.mcp ) { return workspace as Workspace; } @@ -179,6 +183,9 @@ export const scopeToActivity = (scope: WorkspaceScope) => { case WorkspaceScopeKeys.environment: { return 'environment'; } + case WorkspaceScopeKeys.mcp: { + return 'mcp'; + } default: { return 'debug'; } diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index ea0a1b6a09e..8df66178c85 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -245,6 +245,43 @@ export const fetchRequestData = async ( }; }; +export const fetchMcpRequestData = async (mcpRequestId: string) => { + const mcpRequest = await models.mcpRequest.getById(mcpRequestId); + invariant(mcpRequest, 'failed to find MCP request ' + mcpRequestId); + + const workspace = await models.workspace.getById(mcpRequest.parentId); + invariant(workspace, 'failed to find workspace'); + const workspaceId = workspace._id; + const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspaceId); + const activeEnvironmentId = workspaceMeta.activeEnvironmentId; + const activeEnvironment = activeEnvironmentId && (await models.environment.getById(activeEnvironmentId)); + const baseEnvironment = await models.environment.getOrCreateForParentId(workspaceId); + // no active environment in workspaceMeta, fallback to workspace root environment as active environment + const environment = activeEnvironment || baseEnvironment; + invariant(environment, 'failed to find environment ' + activeEnvironmentId); + + const settings = await models.settings.get(); + invariant(settings, 'failed to create settings'); + + const responseId = generateId('res'); + const responsesDir = pathJoin( + process.env['INSOMNIA_DATA_PATH'] || + (process.type === 'renderer' ? window : require('electron')).app.getPath('userData'), + 'responses', + ); + const timelinePath = pathJoin(responsesDir, responseId + '.timeline'); + + return { + environment, + settings, + clientCertificates: [] as ClientCertificate[], + caCert: undefined, + activeEnvironmentId, + timelinePath, + responseId, + }; +}; + export const tryToExecutePreRequestScript = async ( { request, diff --git a/packages/insomnia/src/network/o-auth-2/constants.ts b/packages/insomnia/src/network/o-auth-2/constants.ts index d8c2b987103..f3d9c0c449f 100644 --- a/packages/insomnia/src/network/o-auth-2/constants.ts +++ b/packages/insomnia/src/network/o-auth-2/constants.ts @@ -3,6 +3,7 @@ export const GRANT_TYPE_IMPLICIT = 'implicit'; export const GRANT_TYPE_PASSWORD = 'password'; export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; export const GRANT_TYPE_REFRESH = 'refresh_token'; +export const GRANT_TYPE_MCP_AUTH_FLOW = 'mcp-auth-flow'; export type AuthKeys = | 'access_token' | 'id_token' diff --git a/packages/insomnia/src/network/o-auth-2/get-token.ts b/packages/insomnia/src/network/o-auth-2/get-token.ts index 66dd55a3577..8d8aaf707c4 100644 --- a/packages/insomnia/src/network/o-auth-2/get-token.ts +++ b/packages/insomnia/src/network/o-auth-2/get-token.ts @@ -3,6 +3,8 @@ import querystring from 'node:querystring'; import { v4 as uuidv4 } from 'uuid'; +import { isMcpRequestId } from '~/models/mcp-request'; + import { version } from '../../../package.json'; import { getOauthRedirectUrl } from '../../common/constants'; import { database as db } from '../../common/database'; @@ -19,6 +21,7 @@ import { setDefaultProtocol } from '../../utils/url/protocol'; import { getAuthObjectOrNull, isAuthEnabled } from '../authentication'; import { getBasicAuthHeader } from '../basic-auth/get-header'; import { + fetchMcpRequestData, fetchRequestData, fetchRequestGroupData, responseTransform, @@ -53,6 +56,10 @@ export const getOAuth2Token = async ( forceRefresh = false, ): Promise => { try { + // If it's MCP Auth Flow, should leave it to be handled by the MCP auth provider + if (authentication.grantType === 'mcp-auth-flow') { + return undefined; + } const { oAuth2Token, closestAuthId } = await getExistingAccessTokenAndRefreshIfExpired( requestId, authentication, @@ -252,16 +259,21 @@ async function getExistingAccessTokenAndRefreshIfExpired( authentication: AuthTypeOAuth2, forceRefresh: boolean, ): Promise<{ oAuth2Token: OAuth2Token | undefined; closestAuthId: string }> { - const activeRequest = await models.request.getById(requestId); - const requestGroups = ( - await db.withAncestors(activeRequest, [models.requestGroup.type]) - ).filter(isRequestGroup) as RequestGroup[]; - const closestFolderAuth = [...requestGroups] - .reverse() - .find(({ authentication }) => getAuthObjectOrNull(authentication) && isAuthEnabled(authentication)); - const isRequestAuthEnabled = - getAuthObjectOrNull(activeRequest?.authentication) && isAuthEnabled(activeRequest?.authentication); - const closestAuthId = isRequestAuthEnabled ? requestId : closestFolderAuth?._id || requestId; + let closestAuthId = requestId; + + if (!isMcpRequestId(requestId)) { + const activeRequest = await models.request.getById(requestId); + const requestGroups = ( + await db.withAncestors(activeRequest, [models.requestGroup.type]) + ).filter(isRequestGroup) as RequestGroup[]; + const closestFolderAuth = [...requestGroups] + .reverse() + .find(({ authentication }) => getAuthObjectOrNull(authentication) && isAuthEnabled(authentication)); + const isRequestAuthEnabled = + getAuthObjectOrNull(activeRequest?.authentication) && isAuthEnabled(activeRequest?.authentication); + closestAuthId = isRequestAuthEnabled ? requestId : closestFolderAuth?._id || requestId; + } + const token = await models.oAuth2Token.getByParentId(closestAuthId); if (!token) { return { oAuth2Token: undefined, closestAuthId }; @@ -391,10 +403,12 @@ const sendAccessTokenRequest = async ( ) => { invariant(authentication.accessTokenUrl, 'Missing access token URL'); console.log(`[network] Sending with settings req=${requestOrGroupId}`); - // @TODO unpack oauth into regular timeline and remove oauth timeine dialog + // @TODO unpack oauth into regular timeline and remove oauth timeline dialog const initializedData = isRequestGroupId(requestOrGroupId) ? await fetchRequestGroupData(requestOrGroupId) - : await fetchRequestData(requestOrGroupId); + : isMcpRequestId(requestOrGroupId) + ? await fetchMcpRequestData(requestOrGroupId) + : await fetchRequestData(requestOrGroupId); const { environment, settings, clientCertificates, caCert, activeEnvironmentId, timelinePath, responseId } = initializedData; diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index bd04905bc17..248d566e74f 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -65,7 +65,7 @@ export const links: Route.LinksFunction = () => { export const ErrorBoundary: FC = ({ error }) => { const getErrorMessage = (err: any) => { if (isRouteErrorResponse(err)) { - return err.data; + return typeof err.data === 'string' ? err.data : err.data?.message; } if (err?.message) { @@ -138,12 +138,14 @@ export const useRootLoaderData = () => { export async function clientLoader(_args: Route.ClientLoaderArgs) { const settings = await models.settings.get(); const workspaceCount = await models.workspace.count(); + const mcpWorkspaceCount = await models.workspace.count('mcp'); const userSession = await models.userSession.getOrCreate(); const cloudCredentials = await models.cloudCredential.all(); return { settings, workspaceCount, + mcpWorkspaceCount, userSession, cloudCredentials, }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index abad954d2bc..35cde6a97cc 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -1,4 +1,4 @@ -import type { IconName } from '@fortawesome/fontawesome-svg-core'; +import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; import { Fragment, useEffect, useMemo, useState } from 'react'; import { Button, @@ -85,39 +85,44 @@ import { insomniaFetch } from '~/ui/insomniaFetch'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; +type ProjectScopeKeys = WorkspaceScope | 'unsynced'; export const scopeToLabelMap: Record< - WorkspaceScope | 'unsynced', - 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' + ProjectScopeKeys, + 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client' > = { 'design': 'Document', 'collection': 'Collection', 'mock-server': 'Mock Server', 'unsynced': 'Unsynced', 'environment': 'Environment', + 'mcp': 'MCP Client', }; -export const scopeToIconMap: Record = { +export const scopeToIconMap: Record = { 'design': 'file', 'collection': 'bars', 'mock-server': 'server', 'unsynced': 'cloud-download', 'environment': 'code', + 'mcp': ['fac', 'mcp'] as unknown as IconProp, }; -export const scopeToBgColorMap: Record = { +export const scopeToBgColorMap: Record = { 'design': 'bg-[--color-info]', 'collection': 'bg-[--color-surprise]', 'mock-server': 'bg-[--color-warning]', 'unsynced': 'bg-[--hl-md]', 'environment': 'bg-[--color-font]', + 'mcp': 'bg-[--color-danger]', }; -export const scopeToTextColorMap: Record = { +export const scopeToTextColorMap: Record = { 'design': 'text-[--color-font-info]', 'collection': 'text-[--color-font-surprise]', 'mock-server': 'text-[--color-font-warning]', 'unsynced': 'text-[--color-font]', 'environment': 'text-[--color-bg]', + 'mcp': 'text-[--color-font-danger]', }; export interface InsomniaFile { @@ -125,7 +130,7 @@ export interface InsomniaFile { name: string; remoteId?: string; scope: WorkspaceScope | 'unsynced'; - label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment'; + label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; created: number; lastModifiedTimestamp: number; branch?: string; @@ -147,6 +152,7 @@ export interface ProjectLoaderData { environmentsCount: number; collectionsCount: number; mockServersCount: number; + mcpClientsCount: number; projectsCount: number; activeProject?: Project; activeProjectGitRepository?: GitRepository; @@ -410,6 +416,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { environmentsCount: 0, collectionsCount: 0, mockServersCount: 0, + mcpClientsCount: 0, projectsCount: 0, activeProject: undefined, projects: [], @@ -453,6 +460,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { documentsCount: localFiles.filter(file => file.scope === 'design').length, collectionsCount: localFiles.filter(file => file.scope === 'collection').length, mockServersCount: localFiles.filter(file => file.scope === 'mock-server').length, + mcpClientsCount: localFiles.filter(file => file.scope === 'mcp').length, projectsSyncStatusPromise, }; } @@ -471,6 +479,7 @@ const Component = () => { environmentsCount, collectionsCount, mockServersCount, + mcpClientsCount, documentsCount, projectsCount, learningFeaturePromise, @@ -645,6 +654,7 @@ const Component = () => { const createNewMockServer = () => canCreateMockServer && setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true }); const createNewGlobalEnvironment = () => setNewWorkspaceModalState({ scope: 'environment', isOpen: true }); + const createNewMcpClient = () => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true }); const createNewCollectionWithRequest = () => { if (!activeProject) { @@ -669,7 +679,7 @@ const Component = () => { const createInProjectActionList: { id: string; name: string; - icon: IconName; + icon: IconProp; action: () => void; }[] = [ { @@ -684,6 +694,12 @@ const Component = () => { icon: 'file', action: createNewDocument, }, + { + id: 'new-mcp-client', + name: 'MCP Client', + icon: ['fac', 'mcp'] as unknown as IconProp, + action: createNewMcpClient, + }, { id: 'new-mock-server', name: 'Mock Server', @@ -701,7 +717,7 @@ const Component = () => { const scopeActionList: { id: string; label: string; - icon: IconName; + icon: IconProp; action?: { icon: IconName; label: string; @@ -733,6 +749,16 @@ const Component = () => { run: createNewCollection, }, }, + { + id: 'mcp', + label: `MCP Clients (${mcpClientsCount})`, + icon: ['fac', 'mcp'] as unknown as IconProp, + action: { + icon: 'plus', + label: 'New mcp client', + run: createNewMcpClient, + }, + }, { id: 'mock-server', label: `Mock (${mockServersCount})`, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx index 3e84977d023..eca3022398b 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx @@ -4,6 +4,7 @@ import { href } from 'react-router'; import type { ChangeBufferEvent } from '~/common/database'; import type { CookieJar } from '~/models/cookie-jar'; import * as requestOperations from '~/models/helpers/request-operations'; +import { isMcpRequest, TRANSPORT_TYPES, type TransportType } from '~/models/mcp-request'; import type { RequestAuthentication, RequestHeader } from '~/models/request'; import { isEventStreamRequest, isGraphqlSubscriptionRequest } from '~/models/request'; import { isRequestMeta } from '~/models/request-meta'; @@ -22,7 +23,9 @@ export interface ConnectActionParams { authentication: RequestAuthentication; cookieJar: CookieJar; suppressUserAgent: boolean; + transportType?: TransportType; query?: Record; + env?: Record; } export async function clientAction({ params, request }: Route.ClientActionArgs) { @@ -91,6 +94,17 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) query: rendered.query || {}, }); } + if (isMcpRequest(req)) { + return window.main.mcp.connect({ + requestId, + workspaceId, + transportType: rendered.transportType || TRANSPORT_TYPES.HTTP, + url: rendered.url, + headers: rendered.headers, + authentication: rendered.authentication, + env: rendered.env || {}, + }); + } // HACK: even more elaborate hack to get the request to update return new Promise(resolve => { const unsubscribe = window.main.on('db.changes', async (_, changes: ChangeBufferEvent[]) => { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx new file mode 100644 index 00000000000..c26767a34c9 --- /dev/null +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx @@ -0,0 +1,62 @@ +import { href } from 'react-router'; + +import * as requestOperations from '~/models/helpers/request-operations'; +import type { McpRequest } from '~/models/mcp-request'; +import * as projectModel from '~/models/project'; +import { invariant } from '~/utils/invariant'; +import { createFetcherSubmitHook } from '~/utils/router'; + +import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access'; + +export async function clientAction({ params, request }: Route.ClientActionArgs) { + const { requestId, projectId } = params; + + const req = (await requestOperations.getById(requestId)) as McpRequest; + invariant(req, 'Request not found'); + const { accessLevel } = await request.json(); + + if (accessLevel === 'request') { + await requestOperations.update(req, { mcpStdioAccess: true }); + return; + } + + const project = await projectModel.getById(projectId); + invariant(project, 'Project not found for request'); + if (accessLevel === 'project') { + await projectModel.update(project, { mcpStdioAccess: true }); + } +} + +export const useRequestGrantAccessFetcher = createFetcherSubmitHook( + submit => + ({ + organizationId, + projectId, + workspaceId, + requestId, + accessLevel, + }: { + organizationId: string; + projectId: string; + workspaceId: string; + requestId: string; + accessLevel: 'request' | 'project'; + }) => { + const url = href( + '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/grant-access', + { + organizationId, + projectId, + workspaceId, + requestId, + }, + ); + + return submit(JSON.stringify({ accessLevel }), { + action: url, + method: 'POST', + encType: 'application/json', + }); + }, + clientAction, +); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx index b3b2e9188fb..c4afdded010 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx @@ -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); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx index fee7461d926..122f4f0f57a 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx @@ -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; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx index 220ed001e55..c1123b55928 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx @@ -6,6 +6,9 @@ import * as models from '~/models'; import { type GrpcRequest, isGrpcRequestId } from '~/models/grpc-request'; import type { GrpcRequestMeta } from '~/models/grpc-request-meta'; import * as requestOperations from '~/models/helpers/request-operations'; +import { isMcpRequest, type McpRequest } from '~/models/mcp-request'; +import type { McpPayload } from '~/models/mcp-request-payload'; +import type { McpResponse } from '~/models/mcp-response'; import type { MockRoute } from '~/models/mock-route'; import type { MockServer } from '~/models/mock-server'; import { isGraphqlSubscriptionRequest } from '~/models/request'; @@ -45,6 +48,15 @@ export interface GrpcRequestLoaderData { responses: []; requestVersions: RequestVersion[]; } + +export interface McpRequestLoaderData { + activeRequest: McpRequest; + activeRequestMeta: RequestMeta; + activeResponse: McpResponse; + responses: McpResponse[]; + requestVersions: RequestVersion[]; + requestPayload: McpPayload; +} export interface RequestLoaderData { activeRequest: Request; activeRequestMeta: RequestMeta; @@ -54,7 +66,7 @@ export interface RequestLoaderData { mockServerAndRoutes: (MockServer & { routes: MockRoute[] })[]; } -const getResponseModelName = (request: Request | WebSocketRequest | SocketIORequest | GrpcRequest) => { +const getResponseModelName = (request: Request | WebSocketRequest | SocketIORequest | GrpcRequest | McpRequest) => { const isGraphqlWsRequest = isGraphqlSubscriptionRequest(request); if (isWebSocketRequest(request) || isGraphqlWsRequest) { return 'webSocketResponse'; @@ -62,6 +74,9 @@ const getResponseModelName = (request: Request | WebSocketRequest | SocketIORequ if (isSocketIORequest(request)) { return 'socketIOResponse'; } + if (isMcpRequest(request)) { + return 'mcpResponse'; + } return 'response'; }; @@ -151,6 +166,19 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { requestPayload: socketIOPayload, } as SocketIORequestLoaderData; } + + if (isMcpRequest(activeRequest)) { + const requestPayload = await models.mcpPayload.getByParentIdAndUrl(requestId, activeRequest.url); + return { + activeRequest, + activeRequestMeta, + activeResponse, + requestPayload, + responses, + requestVersions: await models.requestVersion.findByParentId(requestId), + } as McpRequestLoaderData; + } + return { activeRequest, activeRequestMeta, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx index b6ad582098c..39917d6bd6f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx @@ -1,7 +1,10 @@ import { href } from 'react-router'; import * as models from '~/models'; +import { isMcpRequestId } from '~/models/mcp-request'; +import type { McpPayload } from '~/models/mcp-request-payload'; import type { SocketIOPayload } from '~/models/socket-io-payload'; +import { isSocketIORequestId } from '~/models/socket-io-request'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload'; @@ -9,9 +12,14 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId } = params; - const patch = (await request.json()) as Partial; - - await models.socketIOPayload.updateOrCreateByParentId(requestId, patch); + if (isMcpRequestId(requestId)) { + const patch = (await request.json()) as Partial; + await models.mcpPayload.updateOrCreateByParentIdAndUrl(requestId, patch); + return null; + } else if (isSocketIORequestId(requestId)) { + const patch = (await request.json()) as Partial; + await models.socketIOPayload.updateOrCreateByParentId(requestId, patch); + } return null; } @@ -29,7 +37,7 @@ export const useRequestUpdatePayloadActionFetcher = createFetcherSubmitHook( projectId: string; workspaceId: string; requestId: string; - payload: Partial; + payload: Partial; }) => { const url = href( '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/update-payload', @@ -40,7 +48,6 @@ export const useRequestUpdatePayloadActionFetcher = createFetcherSubmitHook( requestId, }, ); - return submit(JSON.stringify(payload), { action: url, method: 'POST', diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx index 959e3336d28..daa27a13362 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx @@ -93,6 +93,7 @@ import { EnvironmentPicker } from '~/ui/components/environment-picker'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { Icon } from '~/ui/components/icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; +import { McpPane } from '~/ui/components/mcp/mcp-pane'; import { showModal } from '~/ui/components/modals'; import { AskModal } from '~/ui/components/modals/ask-modal'; import { CookiesModal } from '~/ui/components/modals/cookies-modal'; @@ -230,6 +231,40 @@ const RequestTiming = ({ requestId }: { requestId: string }) => { ) : null; }; +const DebugEntry = () => { + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + requestId?: string; + requestGroupId?: string; + }; + const { activeRequestGroup } = useRequestGroupLoaderData() || {}; + const { activeWorkspace, activeProject } = useWorkspaceLoaderData()!; + const requestData = useRequestLoaderData(); + const { activeRequest } = requestData || {}; + + useInsomniaTab({ + organizationId, + projectId, + workspaceId, + activeWorkspace, + activeProject, + activeRequest, + activeRequestGroup, + }); + + useCloseConnection({ + organizationId, + }); + + if (activeWorkspace.scope === 'mcp') { + // MCP request under mcp workspace has different layout so we need to render a different component + return ; + } + return ; +}; + const Debug = () => { const { activeWorkspace, @@ -265,8 +300,6 @@ const Debug = () => { const [filter, setFilter] = useLocalStorage(`${workspaceId}:collection-list-filter`); const collection = useFilteredRequests(_collection, filter ?? ''); - const { activeRequestGroup } = useRequestGroupLoaderData() || {}; - const [grpcStates, setGrpcStates] = useState( grpcRequests.map(r => ({ requestId: r._id, @@ -476,10 +509,6 @@ const Debug = () => { }, }); - useCloseConnection({ - organizationId, - }); - const isRealtimeRequest = activeRequest && (isWebSocketRequest(activeRequest) || @@ -781,16 +810,6 @@ const Debug = () => { }; }, [settings.forceVerticalLayout, direction]); - useInsomniaTab({ - organizationId, - projectId, - workspaceId, - activeWorkspace, - activeProject, - activeRequest, - activeRequestGroup, - }); - return ( { ); }; -export default Debug; +export default DebugEntry; const ScratchPadTutorialPanel = () => { const [signUpTipDismissedState, setSignUpTipDismissedState] = useLocalStorage<{ diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx new file mode 100644 index 00000000000..1ffbc2a114f --- /dev/null +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx @@ -0,0 +1,24 @@ +import { redirect } from 'react-router'; + +import * as models from '~/models'; +import { invariant } from '~/utils/invariant'; + +import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp'; + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { projectId, workspaceId, organizationId } = params; + invariant(workspaceId, 'Workspace ID is required'); + invariant(projectId, 'Project ID is required'); + const activeWorkspace = await models.workspace.getById(workspaceId); + invariant(activeWorkspace, 'Workspace not found'); + // Mcp collection only have one request + const activeRequest = await models.mcpRequest.getByParentId(workspaceId); + invariant(activeRequest, 'MCP Request not found'); + // Redirect to the debug page of the only request in the MCP workspace + if (activeRequest) { + return redirect( + `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${activeRequest._id}`, + ); + } + return null; +} diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx index 3f34d9e2caa..ec1b89a3552 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx @@ -21,7 +21,7 @@ import type { RequestGroupMeta } from '~/models/request-group-meta'; import type { RequestMeta } from '~/models/request-meta'; import type { SocketIORequest } from '~/models/socket-io-request'; import type { WebSocketRequest } from '~/models/websocket-request'; -import type { Workspace } from '~/models/workspace'; +import { isMcp, type Workspace } from '~/models/workspace'; import type { WorkspaceMeta } from '~/models/workspace-meta'; import { pushSnapshotOnInitialize } from '~/sync/vcs/initialize-backend-project'; import { VCSInstance } from '~/sync/vcs/insomnia-sync'; @@ -253,7 +253,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const userSession = await models.userSession.getOrCreate(); const isLoggedInIsCloudProjectAndIsNotGitRepo = userSession.id && activeProject.remoteId && !gitRepository; let vcsVersion = null; - if (isLoggedInIsCloudProjectAndIsNotGitRepo) { + if (isLoggedInIsCloudProjectAndIsNotGitRepo && !isMcp(activeWorkspace)) { try { const vcs = VCSInstance(); await vcs.switchAndCreateBackendProjectIfNotExist(workspaceId, activeWorkspace.name); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx index dc846db5ed0..771ae8eb03d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx @@ -2,7 +2,7 @@ import { href, redirect } from 'react-router'; import * as models from '~/models'; import { isRemoteProject, type Project } from '~/models/project'; -import type { Workspace } from '~/models/workspace'; +import { isMcp, type Workspace } from '~/models/workspace'; import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -13,7 +13,7 @@ async function deleteWorkspaceFromCloud(workspace: Workspace, project: Project) const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspace._id); const isGitSync = !!workspaceMeta.gitRepositoryId; - if (isRemoteProject(project) && !isGitSync) { + if (isRemoteProject(project) && !isGitSync && !isMcp(workspace)) { try { const vcs = VCSInstance(); await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx index 984488ed22b..d9759677e89 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx @@ -41,7 +41,11 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const scope = workspaceData.scope; invariant( - scope === 'design' || scope === 'collection' || scope === 'mock-server' || scope === 'environment', + scope === 'design' || + scope === 'collection' || + scope === 'mock-server' || + scope === 'environment' || + scope === 'mcp', 'Scope is required', ); @@ -123,7 +127,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) await database.flushChanges(flushId); const { id } = await models.userSession.getOrCreate(); - if (id && !workspaceMeta.gitRepositoryId && !isGitProject(project) && !isLocalProject(project)) { + if (id && !workspaceMeta.gitRepositoryId && !isGitProject(project) && !isLocalProject(project) && scope !== 'mcp') { const vcs = VCSInstance(); await initializeLocalBackendProjectAndMarkForSync({ vcs, @@ -137,6 +141,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) event = SegmentEvent.collectionCreate; } else if (isEnvironment(workspace)) { event = SegmentEvent.environmentWorkspaceCreate; + } else if (scope === 'mcp') { + event = SegmentEvent.mcpClientWorkspaceCreate; } window.main.trackSegmentEvent({ @@ -170,6 +176,36 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) ); } + if (workspaceData.scope === 'mcp') { + const settings = await models.settings.getOrCreate(); + const defaultHeaders = settings.disableAppVersionUserAgent + ? [] + : [{ name: 'User-Agent', value: `insomnia/${getAppVersion()}` }]; + // Create mcp request when MCP workspace is created + const newMcpRequest = await models.mcpRequest.create({ + parentId: workspace._id, + transportType: 'streamable-http', + url: '', + name: 'My first MCP Client', + headers: defaultHeaders, + description: '', + }); + const requestId = newMcpRequest._id; + + window.main.trackSegmentEvent({ + event: SegmentEvent.mcpClientAdded, + }); + + return redirect( + href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId', { + organizationId, + projectId, + workspaceId: workspace._id, + requestId, + }), + ); + } + return redirect( `${href('/organization/:organizationId/project/:projectId/workspace/:workspaceId', { organizationId, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index abbf0e5cfbf..5016db570cc 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -86,13 +86,14 @@ import { invariant } from '~/utils/invariant'; export const scopeToLabelMap: Record< WorkspaceScope | 'unsynced', - 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' + 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client' > = { 'design': 'Document', 'collection': 'Collection', 'mock-server': 'Mock Server', 'unsynced': 'Unsynced', 'environment': 'Environment', + 'mcp': 'MCP Client', }; export const scopeToIconMap: Record = { @@ -124,7 +125,7 @@ export interface InsomniaFile { name: string; remoteId?: string; scope: WorkspaceScope | 'unsynced'; - label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment'; + label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; created: number; lastModifiedTimestamp: number; branch?: string; diff --git a/packages/insomnia/src/sync/git/project-ne-db-client.ts b/packages/insomnia/src/sync/git/project-ne-db-client.ts index a30a442f56a..607ae39d46f 100644 --- a/packages/insomnia/src/sync/git/project-ne-db-client.ts +++ b/packages/insomnia/src/sync/git/project-ne-db-client.ts @@ -7,7 +7,7 @@ import { database, database as db } from '../../common/database'; import type { InsomniaFile } from '../../common/import-v5-parser'; import { getInsomniaV5DataExport, importInsomniaV5Data } from '../../common/insomnia-v5'; import * as models from '../../models'; -import { isWorkspace, type Workspace } from '../../models/workspace'; +import { isMcp, isWorkspace, type Workspace } from '../../models/workspace'; import type { WorkspaceMeta } from '../../models/workspace-meta'; import Stat from './stat'; import { SystemError } from './system-error'; @@ -140,7 +140,9 @@ export class GitProjectNeDBClient { async readdir(filePath: string) { filePath = path.normalize(filePath); - const workspaces = await db.find(models.workspace.type, { parentId: this._projectId }); + const workspaces = (await db.find(models.workspace.type, { parentId: this._projectId })).filter( + w => !isMcp(w), + ); const workspaceMetas = await db.find(models.workspaceMeta.type, { parentId: { $in: workspaces.map(w => w._id), diff --git a/packages/insomnia/src/templating/types.ts b/packages/insomnia/src/templating/types.ts index 3ede20d64b6..156c85ee5a6 100644 --- a/packages/insomnia/src/templating/types.ts +++ b/packages/insomnia/src/templating/types.ts @@ -4,6 +4,7 @@ import type { CloudProviderCredential } from '../models/cloud-credential'; import type { CookieJar } from '../models/cookie-jar'; import type { Environment, UserUploadEnvironment } from '../models/environment'; import type { GrpcRequest } from '../models/grpc-request'; +import type { McpRequest } from '../models/mcp-request'; import type { OAuth2Token } from '../models/o-auth-2-token'; import type { Project } from '../models/project'; import type { Request } from '../models/request'; @@ -83,10 +84,13 @@ export type RenderContextAncestor = | SocketIORequest | RequestGroup | Workspace + | McpRequest | Project; export type RenderContextOptions = BaseRenderContextOptions & - Partial & { + Partial< + BaseRenderContextOptions & { request: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest } + > & { ancestors?: RenderContextAncestor[]; }; diff --git a/packages/insomnia/src/ui/analytics.ts b/packages/insomnia/src/ui/analytics.ts index 3910cbd88d8..f2809983dac 100644 --- a/packages/insomnia/src/ui/analytics.ts +++ b/packages/insomnia/src/ui/analytics.ts @@ -43,6 +43,8 @@ export enum SegmentEvent { projectUpdated = 'Project Updated', exportStarted = 'Export Started', exportRequestsChosen = 'Export Requests Chosen', + mcpClientWorkspaceCreate = 'MCP Client Workspace Created', + mcpClientAdded = 'MCP Client Added', } type PushPull = 'push' | 'pull'; diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index ddbe5a70440..f952cfc49fa 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -4,6 +4,7 @@ import classnames from 'classnames'; import clone from 'clone'; import CodeMirror, { type CodeMirrorLinkClickCallback, + type EditorChange, type EditorConfiguration, type ShowHintOptions, } from 'codemirror'; @@ -13,7 +14,7 @@ import deepEqual from 'deep-equal'; import { JSONPath } from 'jsonpath-plus'; import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Button, Menu, MenuItem, MenuTrigger, Popover, Toolbar } from 'react-aria-components'; -import * as reactUse from 'react-use'; +import { useLatest, useMount, useUnmount } from 'react-use'; import vkBeautify from 'vkbeautify'; import { DEBOUNCE_MILLIS, isMac } from '~/common/constants'; @@ -105,7 +106,7 @@ export interface CodeEditorProps { // used only for saving env editor state, focusEvent doesn't work well onBlur?: (e: FocusEvent) => void; onFocus?: (e: Event, editor?: CodeMirror.Editor) => void; - onChange?: (value: string) => void; + onChange?: (value: string, changeObj: EditorChange[]) => void; onCursorActivity?: (doc: CodeMirror.Editor) => void; onPaste?: (value: string) => string; onClickLink?: CodeMirrorLinkClickCallback; @@ -556,10 +557,10 @@ export const CodeEditor = memo( codeMirror.current = null; }, []); - reactUse.useMount(() => { + useMount(() => { initEditor(); }); - reactUse.useUnmount(() => { + useUnmount(() => { persistState(); cleanUpEditor(); }); @@ -571,9 +572,11 @@ export const CodeEditor = memo( useEditorRefresh(reinitialize); + const latestOnChangeRef = useLatest(onChange); + useEffect(() => { - const fn = misc.debounce((doc: CodeMirror.Editor) => { - if (onChange) { + const fn = misc.debounce((doc: CodeMirror.Editor, changeObj: EditorChange[]) => { + if (latestOnChangeRef.current) { const value = doc.getValue()?.trim() || ''; // Disable linting if the document reaches a maximum size or is empty const withinLintingThresholds = value.length > 0 && value.length < MAX_SIZE_FOR_LINTING; @@ -589,14 +592,14 @@ export const CodeEditor = memo( const errorMessage = err instanceof Error ? err.message : String(err); console.log('[codemirror] Failed to set CodeMirror option', errorMessage); } - onChange(doc.getValue() || ''); + latestOnChangeRef.current(doc.getValue() || '', changeObj); setOriginalCode(doc.getValue() || ''); } }, DEBOUNCE_MILLIS); codeMirror.current?.on('changes', fn); return () => codeMirror.current?.off('changes', fn); - }, [lintOptions, noLint, onChange]); + }, [lintOptions, noLint, latestOnChangeRef]); useEffect(() => { const handleOnBlur = (_: CodeMirror.Editor, e: FocusEvent) => onBlur?.(e); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts index 45f64dc166d..46733b75e6d 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts @@ -66,6 +66,7 @@ async function _highlightNunjucksTags( // Only mark up Nunjucks tokens that are in the viewport const vp = this.getViewport(); + const readOnly = this.isReadOnly(); for (let lineNo = vp.from; lineNo < vp.to; lineNo++) { const line = this.getLineTokens(lineNo); @@ -156,6 +157,7 @@ async function _highlightNunjucksTags( }); activeMarks.push(mark); el.addEventListener('click', async () => { + if (readOnly) return; // Define the dialog HTML showModal(NunjucksModal, { // @ts-expect-error not a known property of TextMarkerOptions diff --git a/packages/insomnia/src/ui/components/base/checkbox.tsx b/packages/insomnia/src/ui/components/base/checkbox.tsx index e5751355c31..97c6d2828fe 100644 --- a/packages/insomnia/src/ui/components/base/checkbox.tsx +++ b/packages/insomnia/src/ui/components/base/checkbox.tsx @@ -1,6 +1,11 @@ import classnames from 'classnames'; import React, { memo, type ReactNode } from 'react'; -import { Checkbox as RaCheckbox, type CheckboxProps } from 'react-aria-components'; +import { + Checkbox as RaCheckbox, + CheckboxGroup as RaCheckboxGroup, + type CheckboxGroupProps, + type CheckboxProps, +} from 'react-aria-components'; import { Icon } from '../icon'; @@ -16,7 +21,12 @@ export const Checkbox = memo( children: ReactNode; }) => { return ( - +
{ + return ( + + {options.map(option => ( + + {option.label} + + ))} + + ); +}; diff --git a/packages/insomnia/src/ui/components/base/select.tsx b/packages/insomnia/src/ui/components/base/select.tsx new file mode 100644 index 00000000000..528015a0f47 --- /dev/null +++ b/packages/insomnia/src/ui/components/base/select.tsx @@ -0,0 +1,64 @@ +import type { Key } from '@react-types/shared'; +import cn from 'classnames'; +import React from 'react'; +import { + Button, + ListBox, + ListBoxItem, + Popover, + Select as RaSelect, + type SelectProps as RaSelectProps, + SelectValue, +} from 'react-aria-components'; + +import { Icon } from '../icon'; + +interface SelectProps { + value: string; + onChange: (value: Key | null) => void; + className?: string; + options: { label: string; value: string }[]; +} + +export const Select = ({ value, onChange, className, options, ...rest }: SelectProps & RaSelectProps) => { + return ( + + + + + {options?.map(option => ( + + cn('flex min-h-[32px] cursor-pointer items-center px-2 text-[--color-font]', { + 'bg-[--hl-xs]': isHovered || isPressed || isFocused, + }) + } + id={option.value} + key={option.value} + > + {({ isSelected }) => ( + <> + {isSelected && '✓'} + {option.label} + + )} + + ))} + + + + ); +}; + + + +
+ +
+ +
+ + {notificationEvents.length > 0 ? ( + + ) : ( +
+ No notifications found +
+ )} + + {selectedEvent && ( + <> + + +
+ ; +
+
+ + )} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx new file mode 100644 index 00000000000..1e7b4bb94f2 --- /dev/null +++ b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx @@ -0,0 +1,644 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import cn from 'classnames'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Breadcrumb, + Breadcrumbs, + Button, + GridList, + GridListItem, + Input, + SearchField, + ToggleButton, + Tooltip, + TooltipTrigger, +} from 'react-aria-components'; +import { type ImperativePanelGroupHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { NavLink, useParams } from 'react-router'; +import { useLocalStorage } from 'react-use'; + +import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; +import { + getDefaultServerCapabilities, + type McpServerData, + METHOD_INITIALIZE, + METHOD_LIST_PROMPTS, + METHOD_LIST_RESOURCE_TEMPLATES, + METHOD_LIST_RESOURCES, + METHOD_LIST_TOOLS, +} from '~/common/mcp-utils'; +import { fuzzyMatchAll } from '~/common/misc'; +import type { McpEvent, McpMessageEvent } from '~/main/network/mcp'; +import type { McpRequest, McpServerPrimitiveTypes } from '~/models/mcp-request'; +import { useRootLoaderData } from '~/root'; +import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; +import { + type McpRequestLoaderData, + useRequestLoaderData, +} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.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, type RequestPaneTabs } 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 { RealtimeResponsePane } from '~/ui/components/websockets/realtime-response-pane'; +import { INSOMNIA_TAB_HEIGHT } from '~/ui/constant'; +import { useReadyState } from '~/ui/hooks/use-ready-state'; +import { useRequestMetaPatcher, useRequestPatcher } from '~/ui/hooks/use-request'; + +const emptyServerData: McpServerData = { + serverCapabilities: getDefaultServerCapabilities(), + primitives: { tools: [], resources: [], resourceTemplates: [], prompts: [] }, +}; + +export const McpPane = () => { + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; + const { activeRequest, activeResponse, activeRequestMeta } = useRequestLoaderData()! as McpRequestLoaderData; + const sidebarPanelRef = useRef(null); + const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false); + const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false); + const [allExpanded, setAllExpanded] = useState(true); + const [filter, setFilter] = useLocalStorage(`${workspaceId}:mcp-list-filter`); + const { settings } = useRootLoaderData()!; + const [mcpServerData, setMcpServerData] = useState(emptyServerData); + const [collapsedPrimitives, setCollapsedPrimitives] = useState([]); + const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState(null); + const [primitiveNextCursor, setPrimitiveNextCursor] = useState>>({}); + const requestMetaPatcher = useRequestMetaPatcher(); + const [requestPaneActiveTab, setRequestPaneActiveTab] = useState('params'); + const patchRootsRequest = useRequestPatcher(); + const requestId = activeRequest._id; + const { activeEnvironment } = useWorkspaceLoaderData()!; + const readyState = useReadyState({ requestId, protocol: 'mcp' }); + const parentRef = useRef(null); + const [direction, setDirection] = useState<'horizontal' | 'vertical'>( + settings.forceVerticalLayout ? 'vertical' : 'horizontal', + ); + + const subscribeResources = activeRequest.subscribeResources; + + const visibleCollection = useMemo(() => { + const collection: (PrimitiveTypeItem | PrimitiveSubItem)[] = []; + if (mcpServerData) { + const { primitives } = mcpServerData; + const tools = primitives.tools.filter(tool => + filter ? Boolean(fuzzyMatchAll(filter, [tool.name, tool.description || ''])?.indexes) : true, + ); + const resources = primitives.resources.filter(res => + filter ? Boolean(fuzzyMatchAll(filter, [res.name, res.description || '', res.uri])?.indexes) : true, + ); + const resourceTemplates = primitives.resourceTemplates.filter(rt => + filter ? Boolean(fuzzyMatchAll(filter, [rt.name, rt.description || '', rt.uriTemplate])?.indexes) : true, + ); + const prompts = primitives.prompts.filter(prompt => + filter ? Boolean(fuzzyMatchAll(filter, [prompt.name, prompt.description || ''])?.indexes) : true, + ); + // Add primitive type item + if (tools.length > 0) { + collection.push({ + type: 'tools', + name: 'Tools', + collapsed: collapsedPrimitives.includes('tools'), + itemLevel: 0, + hide: false, + ...(primitiveNextCursor.tools && { nextCursor: primitiveNextCursor.tools }), + }); + const hide = collapsedPrimitives.includes('tools'); + collection.push(...(tools.map(t => ({ ...t, type: 'tools', itemLevel: 1, hide })) as ToolItem[])); + } + if (resources.length > 0 || resourceTemplates.length > 0) { + collection.push({ + type: 'resources', + name: 'Resources', + collapsed: collapsedPrimitives.includes('resources'), + itemLevel: 0, + hide: false, + ...(primitiveNextCursor.resources && { nextCursor: primitiveNextCursor.resources }), + }); + const hide = collapsedPrimitives.includes('resources'); + collection.push(...(resources.map(r => ({ ...r, type: 'resources', itemLevel: 1, hide })) as ResourceItem[])); + collection.push( + ...(resourceTemplates.map(rt => ({ + ...rt, + type: 'resourceTemplates', + itemLevel: 1, + hide, + })) as ResourceTemplateItem[]), + ); + } + if (prompts.length > 0) { + collection.push({ + type: 'prompts', + name: 'Prompts', + collapsed: collapsedPrimitives.includes('prompts'), + itemLevel: 0, + hide: false, + ...(primitiveNextCursor.prompts && { nextCursor: primitiveNextCursor.prompts }), + }); + const hide = collapsedPrimitives.includes('prompts'); + collection.push(...(prompts.map(p => ({ ...p, type: 'prompts', itemLevel: 1, hide })) as PromptItem[])); + } + } + return collection.filter(item => !item.hide); + }, [ + collapsedPrimitives, + filter, + mcpServerData, + primitiveNextCursor.prompts, + primitiveNextCursor.resources, + primitiveNextCursor.tools, + ]); + + const getServerCapabilities = () => { + const serverCapabilities = getDefaultServerCapabilities(); + if (mcpServerData) { + const { tools, resources, prompts } = mcpServerData.serverCapabilities; + if (tools) { + serverCapabilities.tools.enabled = true; + serverCapabilities.tools.listChanged = !!tools.listChanged; + } + if (resources) { + serverCapabilities.resources.enabled = true; + serverCapabilities.resources.listChanged = !!resources.listChanged; + serverCapabilities.resources.subscribe = !!resources.subscribe; + } + if (prompts) { + serverCapabilities.prompts.enabled = true; + serverCapabilities.prompts.listChanged = !!prompts.listChanged; + } + } + return serverCapabilities; + }; + const serverCapabilities = getServerCapabilities(); + const allowSubscribeResources = + readyState && serverCapabilities.resources.enabled && serverCapabilities.resources.subscribe; + + const updatePrimitiveNextCursor = (newNextCursor: string, type: McpServerPrimitiveTypes) => { + setPrimitiveNextCursor(prev => ({ + ...prev, + [type]: newNextCursor, + })); + }; + + const updatePrimitiveData = ( + newData: McpServerData['primitives'][McpServerPrimitiveTypes], + type: McpServerPrimitiveTypes, + ) => { + setMcpServerData(prev => ({ + serverCapabilities: prev['serverCapabilities'], + primitives: { + ...prev['primitives'], + [type]: newData, + }, + })); + }; + + const loadMorePrimitiveData = ( + newData: McpServerData['primitives'][McpServerPrimitiveTypes], + type: McpServerPrimitiveTypes, + ) => { + setMcpServerData(prev => ({ + serverCapabilities: prev['serverCapabilities'], + primitives: { + ...prev['primitives'], + [type]: [...prev['primitives'][type], ...newData], + }, + })); + }; + + const handleSubscribe = async (item: ResourceItem) => { + const isSubscribed = subscribeResources.includes(item.name); + if (isSubscribed) { + try { + await window.main.mcp.primitive.unsubscribeResource({ uri: item.uri, requestId: requestId }); + patchRootsRequest(requestId, { subscribeResources: subscribeResources.filter(r => r !== item.name) }); + } catch (error) { + console.error(`Failed to unsubscribe resource ${item.name}: ${error}`); + } + } else { + try { + await window.main.mcp.primitive.subscribeResource({ uri: item.uri, requestId: requestId }); + patchRootsRequest(requestId, { subscribeResources: [...subscribeResources, item.name] }); + } catch (error) { + console.error(`Failed to subscribe resource ${item.name}: ${error}`); + } + } + }; + + useEffect(() => { + const [, type, name] = activeRequestMeta?.activeMcpPrimitive?.match(/^([^_]+)_(.+)$/) || []; + const primitiveItem = visibleCollection.find(i => i.itemLevel === 1 && i.type === type && i.name === name); + primitiveItem && setSelectedPrimitiveItem(primitiveItem as PrimitiveSubItem); + }, [activeRequest._id, activeRequestMeta?.activeMcpPrimitive, visibleCollection]); + + const virtualizer = useVirtualizer({ + getScrollElement: () => parentRef.current, + count: visibleCollection.length, + estimateSize: useCallback(() => 32, []), + overscan: 20, + getItemKey: index => { + const item = visibleCollection[index]; + return `${item.itemLevel}::${item.type}::${item.name}`; + }, + }); + + const toggleSidebar = () => { + const layout = sidebarPanelRef.current?.getLayout(); + + if (!layout) { + return; + } + + if (layout && layout[0] > 0) { + layout[0] = 0; + } else { + layout[0] = DEFAULT_SIDEBAR_SIZE; + } + + sidebarPanelRef.current?.setLayout(layout); + }; + + useEffect(() => { + const unsubscribe = window.main.on('toggle-sidebar', toggleSidebar); + return unsubscribe; + }, []); + + useEffect(() => { + if (settings.forceVerticalLayout) { + setDirection('vertical'); + return () => {}; + } + // Listen on media query changes + const mediaQuery = window.matchMedia('(max-width: 880px)'); + setDirection(mediaQuery.matches ? 'vertical' : 'horizontal'); + + const handleChange = (e: MediaQueryListEvent) => { + setDirection(e.matches ? 'vertical' : 'horizontal'); + }; + + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, [settings.forceVerticalLayout, direction]); + + useEffect(() => { + const updateServerData = async () => { + const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => { + const firstMatchEvent = mcpEvents.find( + event => 'method' in event && event.method === method && event.direction === 'INCOMING', + ) as McpMessageEvent; + if (firstMatchEvent) { + return 'result' in firstMatchEvent.data ? firstMatchEvent.data.result : undefined; + } + return undefined; + }; + const activeResponseId = activeResponse?._id; + if (activeResponseId) { + const allEvents = await window.main.mcp.event.findMany({ responseId: activeResponseId }); + const allMessageEvents = allEvents.filter( + event => 'method' in event && event.direction === 'INCOMING', + ) as McpMessageEvent[]; + const serverCapabilities = + findFirstMatchEventData(allEvents, METHOD_INITIALIZE)?.capabilities || getDefaultServerCapabilities(); + const latestToolListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_TOOLS); + const latestResourceListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_RESOURCES); + const latestResourceTemplateListEvent = findFirstMatchEventData( + allMessageEvents, + METHOD_LIST_RESOURCE_TEMPLATES, + ); + const latestPromptListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_PROMPTS); + const tools = latestToolListEvent?.tools || []; + const resources = latestResourceListEvent?.resources || []; + const resourceTemplates = latestResourceTemplateListEvent?.resourceTemplates || []; + const prompts = latestPromptListEvent?.prompts || []; + // Get nextCursor for each primitive type + const toolsNextCursor = latestToolListEvent?.nextCursor as string | undefined; + const resourcesNextCursor = latestResourceListEvent?.nextCursor as string | undefined; + const promptsNextCursor = latestPromptListEvent?.nextCursor as string | undefined; + const primitiveNextCursor = { + ...(toolsNextCursor && { tools: toolsNextCursor }), + ...(resourcesNextCursor && { resources: resourcesNextCursor }), + ...(promptsNextCursor && { prompts: promptsNextCursor }), + }; + setPrimitiveNextCursor(primitiveNextCursor); + + const mcpServerData = { + serverCapabilities: serverCapabilities, + primitives: { + tools, + resources, + resourceTemplates, + prompts, + }, + } as McpServerData; + setMcpServerData(mcpServerData); + } + }; + if (activeResponse?._id) { + // Get MCP server data when active response changes + updateServerData(); + } else { + // Clear MCP server data when no active response + setMcpServerData(emptyServerData); + } + }, [activeResponse?._id]); + + return ( + + +
+
+
+ + + + + + + + + + + +
+
+ +
+
+ setEnvironmentModalOpen(true)} + /> +
+
+ +
+
+ + +
+ +
+
+ + { + const newState = !allExpanded; + if (newState) { + setCollapsedPrimitives([]); + } else { + setCollapsedPrimitives(['tools', 'resources', 'prompts']); + } + setAllExpanded(newState); + }} + className="flex aspect-square h-full 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]" + > + {({ isSelected }) => ( + + )} + + + {allExpanded ? 'Collapse all' : 'Expand all'} + + +
+ +
+ { + const id = key.toString(); + if (id.startsWith('root_')) { + // Click on primitive type item + const primitiveType = id.split('root_')[1] as McpServerPrimitiveTypes; + setCollapsedPrimitives(prev => { + if (prev.includes(primitiveType)) { + return prev.filter(p => p !== primitiveType); + } + return [...prev, primitiveType]; + }); + } else { + // Click a specified primitive + const [, type, name] = id.match(/^([^_]+)_(.+)$/) || []; + const item = visibleCollection.find(i => i.itemLevel === 1 && i.type === type && i.name === name); + requestMetaPatcher(requestId, { activeMcpPrimitive: id }); + setSelectedPrimitiveItem(item as PrimitiveSubItem); + setRequestPaneActiveTab('params'); + } + }} + > + {virtualItem => { + const item = visibleCollection[virtualItem.index]; + const isSelected = + selectedPrimitiveItem?.type === item.type && selectedPrimitiveItem?.name === item.name; + return ( + + ); + }} + +
+
+ {isEnvironmentModalOpen && setEnvironmentModalOpen(false)} />} +
+
+ + + + + + + + + + + + + + + +
+ ); +}; + +const CollectionGridListItem = (props: { + activeRequest: McpRequest; + item: PrimitiveTypeItem | PrimitiveSubItem; + style: React.CSSProperties; + collapsedPrimitives: McpServerPrimitiveTypes[]; + allowSubscribeResources: boolean; + subscribeResources: string[]; + handleSubscribe: (item: ResourceItem) => void; + onRefreshPrimitive: ( + newData: McpServerData['primitives'][McpServerPrimitiveTypes], + type: McpServerPrimitiveTypes, + ) => void; + onUpdatePrimitiveNextCursor: (newNextCursor: string, type: McpServerPrimitiveTypes) => void; + onLoadMorePrimitive: ( + newData: McpServerData['primitives'][McpServerPrimitiveTypes], + type: McpServerPrimitiveTypes, + ) => void; + isSelected: boolean; +}) => { + const { + item, + style, + collapsedPrimitives, + allowSubscribeResources, + subscribeResources, + handleSubscribe, + isSelected, + ...restProps + } = props; + 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 isRootTypeItem = itemLevel === 0; + const isResourceTypeItem = item.type === 'resources' && itemLevel === 1; + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const triggerRef = useRef(null); + + return ( + +
{ + 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`, + }} + > +
+ {isRootTypeItem && ( + + )} + {item.type === 'tools' && item.itemLevel === 1 && ( + + Tool + + )} + {(item.type === 'resources' || item.type === 'resourceTemplates') && item.itemLevel === 1 && ( + + Res + + )} + {item.type === 'prompts' && item.itemLevel === 1 && ( + + Prompt + + )} + {label} +
+ {isRootTypeItem && ( + + )} + {isResourceTypeItem && allowSubscribeResources && ( + + )} +
+
+ ); +}; + +export default McpPane; diff --git a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx new file mode 100644 index 00000000000..1811662a3c0 --- /dev/null +++ b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx @@ -0,0 +1,403 @@ +import { type RJSFSchema } from '@rjsf/utils'; +import type { EditorChange } from 'codemirror'; +import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Heading, Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-aria-components'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useLatest } from 'react-use'; + +import { docsBase } from '~/common/documentation'; +import { buildResourceJsonSchema, fillUriTemplate } from '~/common/mcp-utils'; +import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; +import { Link } from '~/ui/components/base/link'; +import { EnvironmentKVEditor } from '~/ui/components/editors/environment-key-value-editor/key-value-editor'; +import { Icon } from '~/ui/components/icon'; +import { InsomniaRjsfForm, type InsomniaRjsfFormHandle } from '~/ui/components/rjsf'; + +import { type AuthTypes } from '../../../common/constants'; +import type { Environment, EnvironmentKvPairData } from '../../../models/environment'; +import { getAuthObjectOrNull } from '../../../network/authentication'; +import { + type McpRequestLoaderData, + useRequestLoaderData, +} from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; +import { useRequestPatcher, useRequestPayloadPatcher } from '../../hooks/use-request'; +import { CodeEditor, type CodeEditorHandle } from '../.client/codemirror/code-editor'; +import { AuthWrapper } from '../editors/auth/auth-wrapper'; +import { readOnlyWebsocketPairs, RequestHeadersEditor } from '../editors/request-headers-editor'; +import { Pane } from '../panes/pane'; +import { McpRootsPanel } from './mcp-roots-panel'; +import { McpUrlActionBar } from './mcp-url-bar'; +import type { PrimitiveSubItem } from './types'; + +const supportedAuthTypes: AuthTypes[] = ['basic', 'oauth2', 'bearer', 'apikey']; +export type RequestPaneTabs = 'params' | 'auth' | 'headers' | 'roots'; + +const PaneReadOnlyBanner = () => { + return ( +
+

+ This section is now locked since the connection has already been established. To change these settings, please + disconnect first. +

+
+ ); +}; + +interface Props { + environment: Environment | null; + readyState: boolean; + selectedPrimitiveItem?: PrimitiveSubItem | null; + activeTab: RequestPaneTabs; + onTabChange: (newTab: RequestPaneTabs) => void; +} + +export const McpRequestPane: FC = ({ + environment, + readyState, + selectedPrimitiveItem, + activeTab, + onTabChange, +}) => { + const primitiveId = `${selectedPrimitiveItem?.type}_${selectedPrimitiveItem?.name}`; + const { activeRequest, activeRequestMeta, requestPayload } = useRequestLoaderData()! as McpRequestLoaderData; + const [isCalling, setIsCalling] = useState(false); + const latestRequestPayloadRef = useLatest(requestPayload); + + const { activeProject } = useWorkspaceLoaderData()!; + + const [mcpParams, setMcpParams] = useState>(requestPayload?.params || {}); + + const paramEditorRef = useRef(null); + const rjsfFormRef = useRef(null); + const requestId = activeRequest._id; + const isStdio = activeRequest.transportType === 'stdio'; + + const headersCount = activeRequest.headers.filter(h => !h.disabled).length + readOnlyWebsocketPairs.length; + const patchRequest = useRequestPatcher(); + const mcpPayloadPatcher = useRequestPayloadPatcher(); + const latestPayloadPatcherRef = useLatest(mcpPayloadPatcher); + + // Reset the response pane state when we switch requests, the environment gets modified + const uniqueKey = `${environment?.modified}::${requestId}::${activeRequestMeta?.activeResponseId}`; + const requestAuth = getAuthObjectOrNull(activeRequest.authentication); + const isNoneOrInherited = requestAuth?.type === 'none' || requestAuth === null; + const jsonSchema = useMemo(() => { + if (selectedPrimitiveItem?.type === 'tools') { + return selectedPrimitiveItem?.type === 'tools' ? (selectedPrimitiveItem.inputSchema as RJSFSchema) : undefined; + } else if (selectedPrimitiveItem?.type === 'resources' || selectedPrimitiveItem?.type === 'resourceTemplates') { + const res = buildResourceJsonSchema(selectedPrimitiveItem); + return res; + } else if (selectedPrimitiveItem?.type === 'prompts') { + const properties: Record = {}; + const required: string[] = []; + selectedPrimitiveItem?.arguments?.forEach(arg => { + properties[arg.name] = { + type: 'string', + description: arg?.description || '', + }; + if (arg.required) { + required.push(arg.name); + } + }); + return { + type: 'object', + properties, + required, + } as RJSFSchema; + } + return {}; + }, [selectedPrimitiveItem]); + + const handleRjsfFormChange = useCallback( + (formData: any) => { + setMcpParams(prev => { + return { + ...prev, + [primitiveId]: formData, + }; + }); + if (selectedPrimitiveItem?.type !== 'resourceTemplates' && selectedPrimitiveItem?.type !== 'resources') { + paramEditorRef.current?.setValue(JSON.stringify(formData || {}, null, 2)); + } + }, + [primitiveId, selectedPrimitiveItem?.type], + ); + + const handleSend = async () => { + rjsfFormRef.current?.validate(); + try { + setIsCalling(true); + if (selectedPrimitiveItem?.type === 'tools') { + await window.main.mcp.primitive.callTool({ + name: selectedPrimitiveItem?.name || '', + parameters: mcpParams[primitiveId], + requestId: requestId, + }); + } else if (selectedPrimitiveItem?.type === 'resources') { + await window.main.mcp.primitive.readResource({ + requestId, + uri: selectedPrimitiveItem?.uri || '', + }); + } else if (selectedPrimitiveItem?.type === 'resourceTemplates') { + await window.main.mcp.primitive.readResource({ + requestId, + uri: fillUriTemplate(selectedPrimitiveItem.uriTemplate, mcpParams[primitiveId] || {}), + }); + } else if (selectedPrimitiveItem?.type === 'prompts') { + await window.main.mcp.primitive.getPrompt({ + requestId, + name: selectedPrimitiveItem?.name || '', + parameters: mcpParams[primitiveId], + }); + } + } catch (err) { + console.warn('MCP primitive call error', err); + } finally { + setIsCalling(false); + } + }; + + const handleEnvChange = (data: EnvironmentKvPairData[]) => { + patchRequest(requestId, { env: data }); + }; + + const handleEditorChange = (value: string, changeObj: EditorChange[]) => { + try { + const payload = JSON.parse(value); + const origin = changeObj[0]?.origin; + if (origin !== 'setValue') { + setMcpParams(prev => { + return { + ...prev, + [primitiveId]: payload, + }; + }); + } + } catch (err) {} + }; + + const sendButtonText = useMemo(() => { + if (selectedPrimitiveItem?.type === 'tools') { + return 'Call Tool'; + } else if (selectedPrimitiveItem?.type === 'resources' || selectedPrimitiveItem?.type === 'resourceTemplates') { + return 'Read Resource'; + } else if (selectedPrimitiveItem?.type === 'prompts') { + return 'Get Prompt'; + } + return null; + }, [selectedPrimitiveItem]); + + useEffect(() => { + if (readyState) { + latestPayloadPatcherRef.current(requestId, { params: mcpParams, url: activeRequest.url }); + } + }, [activeRequest.url, mcpParams, latestPayloadPatcherRef, requestId, readyState]); + + useEffect(() => { + readyState && setMcpParams(latestRequestPayloadRef.current?.params || {}); + }, [activeRequest.url, latestRequestPayloadRef, readyState]); + + return ( + +
+ patchRequest(requestId, { url })} + /> +
+ { + const activeTab = key.toString(); + onTabChange(activeTab as RequestPaneTabs); + }} + selectedKey={activeTab} + > + + + Params + + {!isStdio && ( + + Auth + {!isNoneOrInherited && ( + + + + )} + + )} + {!isStdio && ( + + Headers + {headersCount > 0 && ( + + {headersCount} + + )} + + )} + {isStdio && ( + + Environment + + )} + + Roots + {activeRequest.roots.length > 0 && ( + + + + )} + + + + {!readyState ? ( +
+ {/* Hint when mcp server is not connected*/} +

+ Connect to an MCP server URL to reveal capabilities.  Learn More +

+
+ ) : ( + + +
+ + Parameter Builder + {sendButtonText && ( +
+ +
+ )} +
+ {!selectedPrimitiveItem && ( +
+

+ Select an MCP server primitive from the list to start. +

+
+ )} + {jsonSchema && ( +
+

{selectedPrimitiveItem?.name}

+

{selectedPrimitiveItem?.description}

+ {selectedPrimitiveItem?.type === 'resourceTemplates' && ( +

+ uri: {fillUriTemplate(selectedPrimitiveItem.uriTemplate, mcpParams[primitiveId] || {})} +

+ )} +
+ +
+
+ )} +
+
+ + {selectedPrimitiveItem?.type !== 'resources' && selectedPrimitiveItem?.type !== 'resourceTemplates' && ( + +
+ Parameter Overview +
+ +
+
+
+ )} +
+ )} +
+ + {readyState && } + + + + {readyState && } + + + + {readyState && } + + + + + +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/mcp/mcp-roots-panel.tsx b/packages/insomnia/src/ui/components/mcp/mcp-roots-panel.tsx new file mode 100644 index 00000000000..9e556b541c2 --- /dev/null +++ b/packages/insomnia/src/ui/components/mcp/mcp-roots-panel.tsx @@ -0,0 +1,108 @@ +import type { Root } from '@modelcontextprotocol/sdk/types.js'; +import { useState } from 'react'; +import { Button, Heading, ListBox, ListBoxItem, Toolbar } from 'react-aria-components'; + +import type { McpRequest } from '~/models/mcp-request'; +import { PromptButton } from '~/ui/components/base/prompt-button'; +import { useRequestPatcher } from '~/ui/hooks/use-request'; + +interface McpRootsPanelProps { + request: McpRequest; + readyState: boolean; +} + +const rootPrefix = 'file://'; + +export const McpRootsPanel = ({ request, readyState }: McpRootsPanelProps) => { + const [rootUri, setRootUri] = useState(rootPrefix); + const [roots, setRoots] = useState(request.roots); + const [isInvalidRoot, setIsInvalidRoot] = useState(true); + const patchRootsRequest = useRequestPatcher(); + const requestId = request._id; + + const addRoot = () => { + const parsedRoot = rootUri.trim(); + if (parsedRoot.startsWith(rootPrefix) && parsedRoot.length > rootPrefix.length) { + setRoots(currentRoots => { + const newRoots = [...currentRoots, { uri: rootUri.trim() }]; + patchRootsRequest(requestId, { roots: newRoots }); + return newRoots; + }); + setRootUri(rootPrefix); + setIsInvalidRoot(true); + } else { + setIsInvalidRoot(false); + } + }; + + const removeRoot = (rootIdx: number) => { + setRoots(currentRoots => { + const newRoots = currentRoots.filter((_, i) => i !== rootIdx); + patchRootsRequest(requestId, { roots: newRoots }); + return newRoots; + }); + }; + + return ( +
+ + Configure Roots + + + + + {roots.map(({ uri }, idx) => { + const key = `${uri}-${idx}`; + return ( + + + {uri || ''} + +
+ removeRoot(idx)} + title="Delete cookie" + > + + +
+
+ ); + })} +
+ +
+ setRootUri(e.target.value)} + type={'text'} + className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]" + /> + +
+ {!isInvalidRoot && ( +
+

{`Invalid root, please config root directory and must be start with ${rootPrefix}`}

+
+ )} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx new file mode 100644 index 00000000000..79f8679b4ce --- /dev/null +++ b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx @@ -0,0 +1,433 @@ +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'; +import { OverlayContainer } from 'react-aria'; +import { Button as RaButton, Heading, Radio, RadioGroup } from 'react-aria-components'; +import { useParams } from 'react-router'; +import { useLatest } from 'react-use'; + +import { type Project } from '~/models/project'; +import type { AuthTypeOAuth2 } from '~/models/request'; +import { _buildBearerHeader } from '~/network/authentication'; +import { getBasicAuthHeader } from '~/network/basic-auth/get-header'; +import { getBearerAuthHeader } from '~/network/bearer-auth/get-header'; +import { getOAuth2Token } from '~/network/o-auth-2/get-token'; +import { + type ConnectActionParams, + useRequestConnectActionFetcher, +} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect'; +import { useRequestGrantAccessFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access'; +import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor'; +import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '~/ui/components/base/dropdown'; +import { Modal, type ModalHandle } from '~/ui/components/base/modal'; +import { ModalHeader } from '~/ui/components/base/modal-header'; +import { showModal } from '~/ui/components/modals'; +import { AskModal } from '~/ui/components/modals/ask-modal'; +import { Button } from '~/ui/components/themed-button'; + +import { getDataFromKVPair } from '../../../models/environment'; +import { MCP_TRANSPORT_TYPES, type McpRequest, TRANSPORT_TYPES } from '../../../models/mcp-request'; +import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; +import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; +import { useRequestPatcher } from '../../hooks/use-request'; +import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder'; +import { DisconnectButton } from '../websockets/disconnect-button'; + +interface ActionBarProps { + request: McpRequest; + project: Project; + environmentId: string; + defaultValue: string; + readyState: boolean; + onChange: (value: string) => void; +} + +const getTransportLabel = (transportType: McpRequest['transportType']) => + transportType === TRANSPORT_TYPES.HTTP ? 'HTTP' : 'STDIO'; + +export const McpUrlActionBar = ({ + request, + project, + environmentId, + defaultValue, + onChange, + readyState, +}: ActionBarProps) => { + const isOpen = readyState; + const patchRequest = useRequestPatcher(); + const oneLineEditorRef = useRef(null); + const requestId = request._id; + const requestTransportType = request.transportType; + const requestTransportTypeLabel = getTransportLabel(requestTransportType); + const modalRef = useRef(null); + + useLayoutEffect(() => { + oneLineEditorRef.current?.focusEnd(); + }, []); + + const connectRequestFetcher = useRequestConnectActionFetcher(); + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; + + const { updateTabById } = useInsomniaTabContext(); + + const connect = useCallback( + (connectParams: ConnectActionParams) => { + connectRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + requestId, + connectParams, + }); + }, + [connectRequestFetcher, organizationId, projectId, requestId, workspaceId], + ); + + const generateConnectParams = useCallback(async () => { + // Render any nunjucks tags in the url/headers/authentication settings/cookies + const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ + request, + environmentId, + payload: { + url: request.url, + headers: request.headers, + authentication: request.authentication, + env: getDataFromKVPair(request.env).data, + }, + }); + + const { authentication, headers } = rendered; + + if (!authentication.disabled) { + try { + if (authentication.type === 'basic') { + const { username, password, useISO88591 } = authentication; + const encoding = useISO88591 ? 'latin1' : 'utf8'; + headers.push(getBasicAuthHeader(username, password, encoding)); + } else if (authentication.type === 'bearer' && authentication.token) { + const { token, prefix } = authentication; + headers.push(getBearerAuthHeader(token, prefix)); + } else if (authentication.type === 'apikey') { + const { key, value } = authentication; + headers.push({ name: key, value }); + } else if (authentication.type === 'oauth2') { + const oAuth2Token = await getOAuth2Token(request._id, authentication as AuthTypeOAuth2); + if (oAuth2Token) { + const token = oAuth2Token.accessToken; + const authHeader = _buildBearerHeader(token, authentication.tokenPrefix); + if (authHeader) { + headers.push(authHeader); + } + } + } + } catch (error) { + console.error('[mcp] Failed to get auth header', error); + } + } + + return { + url: rendered.url, + transportType: request.transportType, + headers: headers, + authentication: rendered.authentication, + suppressUserAgent: rendered.suppressUserAgent, + cookieJar: rendered.workspaceCookieJar, + env: rendered.env, + }; + }, [environmentId, request]); + + const isConnecting = connectRequestFetcher.state === 'submitting' || connectRequestFetcher.state === 'loading'; + + const handleSubmit = useCallback(async () => { + if (isConnecting) { + return; + } + + updateTabById?.(request._id, { temporary: false }); + if (isOpen) { + window.main.mcp.close({ requestId: request._id }); + return; + } + + const connectParams = await generateConnectParams(); + + if (connectParams.transportType === TRANSPORT_TYPES.STDIO) { + const stdioAccess = await isAllowedToRunSTDIO(request, project, modalRef); + if (!stdioAccess) { + console.log('User denied STDIO access'); + return; + } + } + + connectParams && connect(connectParams); + }, [connect, generateConnectParams, isConnecting, isOpen, project, request, updateTabById]); + + const handleSubmitRef = useLatest(handleSubmit); + + useEffect(() => { + const sendOnMetaEnter = (event: KeyboardEvent) => { + if (event.metaKey && event.key === 'Enter') { + handleSubmitRef.current(); + } + }; + document + .getElementById('sidebar-request-gridlist') + ?.addEventListener('keydown', sendOnMetaEnter, { capture: true }); + return () => { + document + .getElementById('sidebar-request-gridlist') + ?.removeEventListener('keydown', sendOnMetaEnter, { capture: true }); + }; + }, [handleSubmitRef]); + + useDocBodyKeyboardShortcuts({ + request_send: () => handleSubmitRef.current(), + request_focusUrl: () => { + oneLineEditorRef.current?.selectAll(); + }, + }); + + useEffect(() => { + const unsubscribe = window.main.on('mcp-auth-confirmation', async _ => { + let answered = false; + showModal(AskModal, { + title: 'MCP Authentication Confirmation', + message: 'The MCP server is requesting authentication to proceed. Do you wish to continue?', + onDone: async (yes: boolean) => { + if (answered) { + console.error('Already answered MCP auth confirmation, this should not happen.'); + return; + } + answered = true; + window.main.mcp.authConfirmation(yes); + }, + onHide: () => { + if (answered) { + return; + } + answered = true; + window.main.mcp.authConfirmation(false); + }, + }); + }); + return unsubscribe; + }, []); + + const isConnectingOrClosed = !readyState; + const isDropdownDisabled = isOpen || isConnecting; + + return ( + <> +
+ + {requestTransportTypeLabel} + + } + placement="bottom start" + isDisabled={isDropdownDisabled} + > + + {MCP_TRANSPORT_TYPES.map(transportType => ( + + patchRequest(request._id, { transportType })} + /> + + ))} + + +
+
{ + event.preventDefault(); + handleSubmit(); + }} + > +
+ handleSubmit(), + })} + readOnly={readyState} + defaultValue={defaultValue} + onChange={onChange} + type="text" + /> +
+
+ {isConnectingOrClosed ? ( + + ) : ( + + )} +
+
+ + + ); +}; + +const isAllowedToRunSTDIO = async ( + request: McpRequest, + project: Project, + modalRef: React.RefObject, +) => { + if (request.mcpStdioAccess) { + return true; + } + + if (project.mcpStdioAccess) { + return true; + } + + const promise = new Promise(resolve => { + let granted = false; + modalRef.current?.show({ + onHide: () => { + if (!granted) { + resolve(false); + } + }, + onGrant: () => { + resolve(true); + granted = true; + }, + }); + }); + + return promise; +}; + +export interface MCPStdioAccessModalHandle { + show: ({ onGrant, onHide }: { onGrant: () => void; onHide: () => void }) => void; +} +export const MCPStdioAccessModal = forwardRef< + MCPStdioAccessModalHandle, + { + requestId: string; + workspaceId: string; + projectId: string; + organizationId: string; + } +>(({ requestId, workspaceId, projectId, organizationId }, ref) => { + const [accessLevel, setAccessLevel] = React.useState<'request' | 'project'>('request'); + + const modalRef = useRef(null); + const onGrantRef = useRef<() => void>(() => {}); + const onHideRef = useRef<() => void>(() => {}); + + const requestGrantAccessFetcher = useRequestGrantAccessFetcher(); + + const isSubmitting = + requestGrantAccessFetcher.state === 'submitting' || requestGrantAccessFetcher.state === 'loading'; + + const handleHide = () => { + if (isSubmitting) return; + onHideRef.current(); + onGrantRef.current = () => {}; + onHideRef.current = () => {}; + }; + + const handleGrant = async () => { + await requestGrantAccessFetcher.submit({ + accessLevel, + requestId, + workspaceId, + projectId, + organizationId, + }); + onGrantRef.current(); + modalRef.current?.hide(); + }; + + useImperativeHandle( + ref, + () => ({ + show: ({ onGrant, onHide }) => { + onGrantRef.current = onGrant; + onHideRef.current = onHide; + modalRef.current?.show(); + }, + }), + [], + ); + + return ( + e.stopPropagation()}> + + Grant STDIO access for this MCP Client? +

You should be sure you understand and trust this STDIO server before using it.

+

Trust and give access to:

+
+ setAccessLevel(accessLevel as 'request' | 'project')} + > + +
+ This MCP client only +
+
+ +
+ All MCP clients in this project +
+
+
+ +
+ + +
+
+
+
+ ); +}); diff --git a/packages/insomnia/src/ui/components/mcp/types.ts b/packages/insomnia/src/ui/components/mcp/types.ts new file mode 100644 index 00000000000..457068809f0 --- /dev/null +++ b/packages/insomnia/src/ui/components/mcp/types.ts @@ -0,0 +1,27 @@ +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 interface ToolItem extends Tool, CommonItemProps { + type: 'tools'; +} +export interface ResourceItem extends Resource, CommonItemProps { + type: 'resources'; +} +export interface ResourceTemplateItem extends ResourceTemplate, CommonItemProps { + type: 'resourceTemplates'; +} +export interface PromptItem extends Prompt, CommonItemProps { + type: 'prompts'; +} +export type PrimitiveSubItem = ToolItem | ResourceItem | ResourceTemplateItem | PromptItem; +export interface PrimitiveTypeItem extends CommonItemProps { + type: McpServerPrimitiveTypes; + name: string; + nextCursor?: string; +} diff --git a/packages/insomnia/src/ui/components/modals/ask-modal.tsx b/packages/insomnia/src/ui/components/modals/ask-modal.tsx index 2cc8e53d03d..3bc1801825c 100644 --- a/packages/insomnia/src/ui/components/modals/ask-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/ask-modal.tsx @@ -11,11 +11,13 @@ interface State { noText: string; color: string; onDone?: (success: boolean) => Promise; + onHide?: () => void; } export interface AskModalOptions { title?: string; message: React.ReactNode; onDone?: (success: boolean) => Promise; + onHide?: () => void; yesText?: string; noText?: string; color?: string; @@ -41,7 +43,7 @@ export const AskModal = forwardRef((_, ref) => { hide: () => { modalRef.current?.hide(); }, - show: ({ title, message, onDone, yesText, noText, color }) => { + show: ({ title, message, onDone, onHide, yesText, noText, color }) => { setState({ title: title || 'Confirm', message: message || 'No message provided', @@ -49,15 +51,16 @@ export const AskModal = forwardRef((_, ref) => { noText: noText || 'No', color: color || 'surprise', onDone, + onHide, }); modalRef.current?.show(); }, }), [], ); - const { message, title, yesText, noText, color, onDone } = state; + const { message, title, yesText, noText, color, onDone, onHide } = state; return ( - + {title || 'Confirm?'} {message} @@ -65,8 +68,8 @@ export const AskModal = forwardRef((_, ref) => { + + + ); +}; + +const MultiSchemaFieldTemplate = (props: MultiSchemaFieldTemplateProps) => { + const { optionSchemaField, selector } = props; + + return ( +
+
{selector}
+ {optionSchemaField} +
+ ); +}; + +// Field Template - controls the layout of each field +const FieldTemplate = (props: FieldTemplateProps) => { + const { + id, + classNames, + style, + label, + help, + required, + description, + rawDescription, + errors, + children, + displayLabel, + hidden, + schema, + registry, + rawErrors, + rawHelp, + } = props; + + if (hidden) { + return
{children}
; + } + + const displayDescription = schema?.type !== 'boolean' && description && rawDescription; + // always show label for boolean fields + const comDisplayLabel = schema?.type === 'boolean' || displayLabel; + + const WrapIfAdditionalTemplate = registry.templates.WrapIfAdditionalTemplate; + + return ( +
+ + {comDisplayLabel && label && ( + <> + + {displayDescription &&
{description}
} + + )} + {children} + {rawErrors &&
{errors}
} + {rawHelp &&
{help}
} +
+
+ ); +}; + +const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => { + const { title, description, properties, required, schema, idSchema, onAddClick } = props; + + const level = idSchema.$id.split('_').length; + + const canExpand = schema.additionalItems || schema.additionalProperties; + + return ( +
+ {title && ( + + )} + {description &&
{description}
} +
1, + 'pl-4': level > 1, + })} + > + {properties.map(prop => ( +
+ {prop.content} +
+ ))} +
+ {canExpand && ( +
+ +
+ )} +
+ ); +}; + +const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => { + const { title, items, canAdd, onAddClick, disabled, readonly, required, schema } = props; + + return ( +
+ {title && ( + + )} + {schema.description &&
{schema.description}
} + +
+ {items.map(item => ( +
+
{item.children}
+
+ +
+
+ ))} + {canAdd && ( +
+ +
+ )} +
+
+ ); +}; + +// ===== REGISTRY ===== + +const themeWidgets: RegistryWidgetsType = { + CheckboxWidget: CustomCheckboxWidget, + SelectWidget: CustomSelectWidget, +}; + +const themeTemplates = { + BaseInputTemplate, + FieldTemplate, + ObjectFieldTemplate, + ArrayFieldTemplate, + WrapIfAdditionalTemplate, + MultiSchemaFieldTemplate, +}; + +const ThemeObject: ThemeProps = { + widgets: themeWidgets, + templates: themeTemplates, +}; + +export default ThemeObject; diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index 5fb8da10957..027d5430cf0 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -15,7 +15,7 @@ import * as models from 'insomnia/src/models/index'; import { type BaseModel, environment } from 'insomnia/src/models/index'; import { isScratchpadOrganizationId, type Organization } from 'insomnia/src/models/organization'; import type { Project } from 'insomnia/src/models/project'; -import { isScratchpad, type Workspace } from 'insomnia/src/models/workspace'; +import { isMcp, isScratchpad, type Workspace } from 'insomnia/src/models/workspace'; import { SegmentEvent } from 'insomnia/src/ui/analytics'; import { Icon } from 'insomnia/src/ui/components/icon'; import { showError, showModal } from 'insomnia/src/ui/components/modals'; @@ -389,8 +389,10 @@ export async function exportWorkspaceData({ export async function exportAllData({ dirPath }: { dirPath: string }): Promise { const workspaces = await database.find(models.workspace.type); + const workspacesWithoutMcp = workspaces.filter(w => !isMcp(w)); + const baseEnvironments = await database.find(environment.type, { - parentId: { $in: workspaces.map(w => w._id) }, + parentId: { $in: workspacesWithoutMcp.map(w => w._id) }, }); const subEnvironments = await database.find(environment.type, { @@ -405,7 +407,7 @@ export async function exportAllData({ dirPath }: { dirPath: string }): Promise = ({ hideSettingsModal, onModalChange }) => const workspaceData = useWorkspaceLoaderData(); const activeWorkspaceName = workspaceData?.activeWorkspace.name; - const { workspaceCount, userSession } = useRootLoaderData()!; + const { workspaceCount, userSession, mcpWorkspaceCount } = useRootLoaderData()!; const workspacesFetcher = useProjectListWorkspacesLoaderFetcher(); useEffect(() => { const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data; @@ -644,7 +646,11 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => } }, [organizationId, projectId, workspacesFetcher]); const projectLoaderData = workspacesFetcher?.data; - const workspacesForActiveProject = projectLoaderData?.files.map(w => w.workspace).filter(isNotNullOrUndefined) || []; + const workspacesForActiveProject = + projectLoaderData?.files + .map(w => w.workspace) + .filter(isNotNullOrUndefined) + .filter(w => !isMcp(w)) || []; const activeProject = projectLoaderData?.activeProject; const projectName = activeProject?.name ?? getProductName(); const projects = projectLoaderData?.projects || []; @@ -710,7 +716,7 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => aria-label="Export all data" > - Export all data {`(${workspaceCount} files)`} + Export all data {`(${workspaceCount - mcpWorkspaceCount} files)`} ); } @@ -778,7 +784,7 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => aria-label="Export all data" > - Export all data {`(${workspaceCount} files)`} + Export all data {`(${workspaceCount - mcpWorkspaceCount} files)`}