diff --git a/package-lock.json b/package-lock.json index e14c742a38e..8cc540caa7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*", "scripts" ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, "devDependencies": { "@eslint/js": "9.25.1", "@testcontainers/elasticsearch": "10.24.2", @@ -4552,6 +4555,334 @@ "react": ">=16.8.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz", + "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "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==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "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==", + "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==", + "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.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@nangohq/billing": { "resolved": "packages/billing", "link": true @@ -16742,6 +17073,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "license": "MIT", @@ -16817,6 +17169,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express-session": { "version": "1.17.3", "license": "MIT", @@ -18772,6 +19139,12 @@ "optional": true, "peer": true }, + "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==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -21691,6 +22064,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "dev": true, @@ -23204,6 +23586,54 @@ "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==", + "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/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/rsa-pem-from-mod-exp": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.6.tgz", @@ -27658,6 +28088,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zustand": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", @@ -28633,6 +29072,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.2", "@nangohq/billing": "file:../billing", "@nangohq/database": "file:../database", "@nangohq/fleet": "file:../fleet", diff --git a/package.json b/package.json index 2b0532d2074..f5442d6e723 100644 --- a/package.json +++ b/package.json @@ -90,5 +90,8 @@ }, "engines": { "node": ">=18.0.0 || >=20.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" } } diff --git a/packages/server/lib/controllers/config.controller.ts b/packages/server/lib/controllers/config.controller.ts index ad678125687..ca6e0f97136 100644 --- a/packages/server/lib/controllers/config.controller.ts +++ b/packages/server/lib/controllers/config.controller.ts @@ -6,10 +6,10 @@ import { connectionService, errorManager, flowService, - getActionsByProviderConfigKey, getGlobalWebhookReceiveUrl, getProvider, getProviders, + getSimplifiedActionsByProviderConfigKey, getSyncConfigsAsStandardConfig, getUniqueSyncsByProviderConfig } from '@nangohq/shared'; @@ -172,7 +172,7 @@ class ConfigController { }; }); - const actions = await getActionsByProviderConfigKey(environmentId, providerConfigKey); + const actions = await getSimplifiedActionsByProviderConfigKey(environmentId, providerConfigKey); const hasWebhook = provider.webhook_routing_script; let webhookUrl: string | null = null; if (hasWebhook) { diff --git a/packages/server/lib/controllers/mcp/mcp.ts b/packages/server/lib/controllers/mcp/mcp.ts new file mode 100644 index 00000000000..40386a52e5e --- /dev/null +++ b/packages/server/lib/controllers/mcp/mcp.ts @@ -0,0 +1,76 @@ +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; + +import { connectionService } from '@nangohq/shared'; +import { zodErrorToHTTP } from '@nangohq/utils'; + +import { createMcpServerForConnection } from './server.js'; +import { connectionIdSchema, providerConfigKeySchema } from '../../helpers/validation.js'; +import { asyncWrapper } from '../../utils/asyncWrapper.js'; + +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { GetMcp, PostMcp } from '@nangohq/types'; + +export const validationHeaders = z + .object({ + 'connection-id': connectionIdSchema, + 'provider-config-key': providerConfigKeySchema + }) + .strict(); + +export const postMcp = asyncWrapper(async (req, res) => { + const valHeaders = validationHeaders.safeParse({ 'connection-id': req.get('connection-id'), 'provider-config-key': req.get('provider-config-key') }); + if (!valHeaders.success) { + res.status(400).send({ error: { code: 'invalid_headers', errors: zodErrorToHTTP(valHeaders.error) } }); + return; + } + + const { environment, account } = res.locals; + const headers: PostMcp['Headers'] = valHeaders.data; + + const connectionId = headers['connection-id']; + const providerConfigKey = headers['provider-config-key']; + + const { error, response: connection } = await connectionService.getConnection(connectionId, providerConfigKey, environment.id); + + if (error || !connection) { + res.status(400).send({ + error: { code: 'unknown_connection', message: 'Provided connection-id and provider-config-key do not match a valid connection' } + }); + return; + } + + const result = await createMcpServerForConnection(account, environment, connection, providerConfigKey); + if (result.isErr()) { + res.status(500).send({ error: { code: 'Internal server error', message: result.error.message } }); + return; + } + + const server = result.value; + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + res.on('close', () => { + void transport.close(); + void server.close(); + }); + + // Casting because 'exactOptionalPropertyTypes: true' says `?: string` is not equal to `string | undefined` + await server.connect(transport as Transport); + await transport.handleRequest(req, res, req.body); +}); + +// We have to be explicit about not supporting SSE +export const getMcp = asyncWrapper((_, res) => { + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); +}); diff --git a/packages/server/lib/controllers/mcp/server.ts b/packages/server/lib/controllers/mcp/server.ts new file mode 100644 index 00000000000..0b1b7034297 --- /dev/null +++ b/packages/server/lib/controllers/mcp/server.ts @@ -0,0 +1,158 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'; +import tracer from 'dd-trace'; + +import { OtlpSpan, defaultOperationExpiration, logContextGetter } from '@nangohq/logs'; +import { configService, getActionsByProviderConfigKey } from '@nangohq/shared'; +import { Err, Ok, truncateJson } from '@nangohq/utils'; + +import { getOrchestrator } from '../../utils/utils.js'; + +import type { CallToolRequest, CallToolResult, Tool } from '@modelcontextprotocol/sdk/types'; +import type { Config } from '@nangohq/shared'; +import type { DBConnectionDecrypted, DBEnvironment, DBSyncConfig, DBTeam, Result } from '@nangohq/types'; +import type { Span } from 'dd-trace'; +import type { JSONSchema7 } from 'json-schema'; + +export async function createMcpServerForConnection( + account: DBTeam, + environment: DBEnvironment, + connection: DBConnectionDecrypted, + providerConfigKey: string +): Promise> { + const server = new Server( + { + name: 'Nango MCP server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + const providerConfig = await configService.getProviderConfig(providerConfigKey, environment.id); + + if (!providerConfig) { + return Err(new Error(`Provider config ${providerConfigKey} not found`)); + } + + const actions = await getActionsForProvider(environment, providerConfig); + + server.setRequestHandler(ListToolsRequestSchema, () => { + return { + tools: actions.flatMap((action) => { + const tool = actionToTool(action); + return tool ? [tool] : []; + }) + }; + }); + + server.setRequestHandler(CallToolRequestSchema, callToolRequestHandler(actions, account, environment, connection, providerConfig)); + + return Ok(server); +} + +async function getActionsForProvider(environment: DBEnvironment, providerConfig: Config): Promise { + return getActionsByProviderConfigKey(environment.id, providerConfig.unique_key); +} + +function actionToTool(action: DBSyncConfig): Tool | null { + const inputSchema = + action.input && action.models_json_schema?.definitions && action.models_json_schema?.definitions?.[action.input] + ? (action.models_json_schema.definitions[action.input] as JSONSchema7) + : ({ type: 'object' } as JSONSchema7); + + if (inputSchema.type !== 'object') { + // Invalid input schema, skip this action + return null; + } + + const description = action.metadata.description || action.sync_name; + + return { + name: action.sync_name, + inputSchema: { + type: 'object', + properties: inputSchema.properties, + required: inputSchema.required + }, + description + }; +} + +function callToolRequestHandler( + actions: DBSyncConfig[], + account: DBTeam, + environment: DBEnvironment, + connection: DBConnectionDecrypted, + providerConfig: Config +): (request: CallToolRequest) => Promise { + return async (request: CallToolRequest) => { + const active = tracer.scope().active(); + const span = tracer.startSpan('server.mcp.triggerAction', { + childOf: active as Span + }); + + const { name, arguments: toolArguments } = request.params; + + const action = actions.find((action) => action.sync_name === name); + + if (!action) { + span.finish(); + throw new Error(`Action ${name} not found`); + } + + const input = toolArguments ?? {}; + + span.setTag('nango.actionName', action.sync_name) + .setTag('nango.connectionId', connection.id) + .setTag('nango.environmentId', environment.id) + .setTag('nango.providerConfigKey', providerConfig.unique_key); + + const logCtx = await logContextGetter.create( + { operation: { type: 'action', action: 'run' }, expiresAt: defaultOperationExpiration.action() }, + { + account, + environment, + integration: { id: providerConfig.id!, name: providerConfig.unique_key, provider: providerConfig.provider }, + connection: { id: connection.id, name: connection.connection_id }, + syncConfig: { id: action.id, name: action.sync_name }, + meta: truncateJson({ input }) + } + ); + logCtx.attachSpan(new OtlpSpan(logCtx.operation)); + + const actionResponse = await getOrchestrator().triggerAction({ + accountId: account.id, + connection, + actionName: action.sync_name, + input, + async: false, + retryMax: 3, + logCtx + }); + + if (actionResponse.isOk()) { + if (!('data' in actionResponse.value)) { + // Shouldn't happen with sync actions. + return { + content: [] + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(actionResponse.value.data, null, 2) + } + ] + }; + } else { + span.setTag('nango.error', actionResponse.error); + throw new Error(actionResponse.error.message); + } + }; +} diff --git a/packages/server/lib/routes.public.ts b/packages/server/lib/routes.public.ts index 62e6dc6f803..f2de75f53c1 100644 --- a/packages/server/lib/routes.public.ts +++ b/packages/server/lib/routes.public.ts @@ -5,6 +5,7 @@ import multer from 'multer'; import { connectUrl, flagEnforceCLIVersion } from '@nangohq/utils'; +import { getAsyncActionResult } from './controllers/action/getAsyncActionResult.js'; import appAuthController from './controllers/appAuth.controller.js'; import { postPublicApiKeyAuthorization } from './controllers/auth/postApiKey.js'; import { postPublicAppStoreAuthorization } from './controllers/auth/postAppStore.js'; @@ -37,6 +38,7 @@ import { postPublicIntegration } from './controllers/integrations/postIntegratio import { deletePublicIntegration } from './controllers/integrations/uniqueKey/deleteIntegration.js'; import { getPublicIntegration } from './controllers/integrations/uniqueKey/getIntegration.js'; import { patchPublicIntegration } from './controllers/integrations/uniqueKey/patchIntegration.js'; +import { getMcp, postMcp } from './controllers/mcp/mcp.js'; import oauthController from './controllers/oauth.controller.js'; import providerController from './controllers/provider.controller.js'; import { getPublicProvider } from './controllers/providers/getProvider.js'; @@ -61,7 +63,6 @@ import { resourceCapping } from './middleware/resource-capping.middleware.js'; import { isBinaryContentType } from './utils/utils.js'; import type { Request, RequestHandler } from 'express'; -import { getAsyncActionResult } from './controllers/action/getAsyncActionResult.js'; const apiAuth: RequestHandler[] = [authMiddleware.secretKeyAuth.bind(authMiddleware), rateLimiterMiddleware]; const connectSessionAuth: RequestHandler[] = [authMiddleware.connectSessionAuth.bind(authMiddleware), rateLimiterMiddleware]; @@ -218,6 +219,10 @@ publicAPI.route('/sync/status').get(apiAuth, syncController.getSyncStatus.bind(s publicAPI.route('/sync/:name/variant/:variant').post(apiAuth, postSyncVariant); publicAPI.route('/sync/:name/variant/:variant').delete(apiAuth, deleteSyncVariant); +publicAPI.use('/mcp', jsonContentTypeMiddleware); +publicAPI.route('/mcp').post(apiAuth, postMcp); +publicAPI.route('/mcp').get(apiAuth, getMcp); + publicAPI.use('/flow', jsonContentTypeMiddleware); publicAPI.route('/flow/attributes').get(apiAuth, syncController.getFlowAttributes.bind(syncController)); // @deprecated use /scripts/configs diff --git a/packages/server/package.json b/packages/server/package.json index bdb3f19b68e..8ccc21047ea 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,6 +23,8 @@ "npm": ">=6.14.11" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.2", + "@nangohq/billing": "file:../billing", "@nangohq/database": "file:../database", "@nangohq/fleet": "file:../fleet", "@nangohq/keystore": "file:../keystore", @@ -34,7 +36,6 @@ "@nangohq/shared": "file:../shared", "@nangohq/utils": "file:../utils", "@nangohq/webhooks": "file:../webhooks", - "@nangohq/billing": "file:../billing", "@workos-inc/node": "6.2.0", "axios": "1.9.0", "body-parser": "1.20.3", diff --git a/packages/shared/lib/services/sync/config/config.service.ts b/packages/shared/lib/services/sync/config/config.service.ts index 426b33a2217..8d026c9dcc4 100644 --- a/packages/shared/lib/services/sync/config/config.service.ts +++ b/packages/shared/lib/services/sync/config/config.service.ts @@ -236,7 +236,29 @@ export async function getActionConfigByNameAndProviderConfigKey(environment_id: return false; } -export async function getActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise { +export async function getActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise { + const nango_config_id = await configService.getIdByProviderConfigKey(environment_id, unique_key); + + if (!nango_config_id) { + return []; + } + + const result = await schema().from(TABLE).where({ + environment_id, + nango_config_id, + deleted: false, + active: true, + type: 'action' + }); + + if (result) { + return result; + } + + return []; +} + +export async function getSimplifiedActionsByProviderConfigKey(environment_id: number, unique_key: string): Promise { const nango_config_id = await configService.getIdByProviderConfigKey(environment_id, unique_key); if (!nango_config_id) { diff --git a/packages/types/lib/index.ts b/packages/types/lib/index.ts index 8f0e8c33017..a55c54c5226 100644 --- a/packages/types/lib/index.ts +++ b/packages/types/lib/index.ts @@ -75,3 +75,5 @@ export type * from './fleet/index.js'; export type * from './persist/api.js'; export type * from './jobs/api.js'; + +export type * from './mcp/api.js'; diff --git a/packages/types/lib/mcp/api.ts b/packages/types/lib/mcp/api.ts new file mode 100644 index 00000000000..3195d198082 --- /dev/null +++ b/packages/types/lib/mcp/api.ts @@ -0,0 +1,19 @@ +import type { ApiError, Endpoint } from '../api.js'; + +export type PostMcp = Endpoint<{ + Method: 'POST'; + Path: '/mcp'; + Body: Record; + Headers: { + 'connection-id': string; + 'provider-config-key': string; + }; + Success: Record; + Error: ApiError<'missing_connection_id' | 'unknown_connection'>; +}>; + +export type GetMcp = Endpoint<{ + Method: 'GET'; + Path: '/mcp'; + Success: Record; +}>; diff --git a/packages/types/lib/syncConfigs/db.ts b/packages/types/lib/syncConfigs/db.ts index 0c3cb32d8a6..a7cf4414cf8 100644 --- a/packages/types/lib/syncConfigs/db.ts +++ b/packages/types/lib/syncConfigs/db.ts @@ -1,7 +1,7 @@ -import type { JSONSchema7 } from 'json-schema'; import type { TimestampsAndDeleted } from '../db'; import type { LegacySyncModelSchema, NangoConfigMetadata } from '../deploy/incomingFlow'; import type { NangoModel, ScriptTypeLiteral, SyncTypeLiteral } from '../nangoYaml'; +import type { JSONSchema7 } from 'json-schema'; export interface DBSyncConfig extends TimestampsAndDeleted { id: number;