Merge pull request #4988 from Infisical/feat/ai-product

feat: agentic manager
This commit is contained in:
Sheen
2025-12-20 04:03:44 +08:00
committed by GitHub
144 changed files with 18618 additions and 5134 deletions

View File

@@ -34,6 +34,7 @@
"@gitbeaker/rest": "^42.5.0",
"@google-cloud/kms": "^4.5.0",
"@infisical/quic": "^1.0.8",
"@modelcontextprotocol/sdk": "^1.24.3",
"@node-saml/passport-saml": "^5.1.0",
"@octokit/auth-app": "^7.1.1",
"@octokit/core": "^5.2.1",
@@ -9566,6 +9567,411 @@
"@matrixai/errors": "^1.1.7"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz",
"integrity": "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"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",
"jose": "^6.1.1",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"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/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.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.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"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": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"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/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/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==",
"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/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.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"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.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"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/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"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/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
@@ -14950,6 +15356,22 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -17036,7 +17458,6 @@
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -18709,6 +19130,15 @@
"node": ">=12.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==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -18792,6 +19222,21 @@
"url": "https://opencollective.com/express"
}
},
"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==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
@@ -21055,6 +21500,12 @@
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
"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-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -21266,9 +21717,9 @@
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -23429,9 +23880,13 @@
}
},
"node_modules/object-inspect": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz",
"integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -26349,6 +26804,15 @@
"node": ">= 6"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkcs11js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-2.1.6.tgz",
@@ -27998,6 +28462,49 @@
"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.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"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==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
@@ -28451,15 +28958,69 @@
"integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="
},
"node_modules/side-channel": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
"integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.4",
"object-inspect": "^1.13.1"
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"

View File

@@ -165,6 +165,7 @@
"@gitbeaker/rest": "^42.5.0",
"@google-cloud/kms": "^4.5.0",
"@infisical/quic": "^1.0.8",
"@modelcontextprotocol/sdk": "^1.24.3",
"@node-saml/passport-saml": "^5.1.0",
"@octokit/auth-app": "^7.1.1",
"@octokit/core": "^5.2.1",

View File

@@ -5,6 +5,9 @@ import { Cluster, Redis } from "ioredis";
import { TUsers } from "@app/db/schemas";
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { TAiMcpActivityLogServiceFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service";
import { TAiMcpEndpointServiceFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service";
import { TAiMcpServerServiceFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-service";
import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types";
import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
@@ -362,6 +365,9 @@ declare module "fastify" {
convertor: TConvertorServiceFactory;
subOrganization: TSubOrgServiceFactory;
pkiAlertV2: TPkiAlertV2ServiceFactory;
aiMcpServer: TAiMcpServerServiceFactory;
aiMcpEndpoint: TAiMcpEndpointServiceFactory;
aiMcpActivityLog: TAiMcpActivityLogServiceFactory;
approvalPolicy: TApprovalPolicyServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data

View File

@@ -20,6 +20,27 @@ import {
TAdditionalPrivileges,
TAdditionalPrivilegesInsert,
TAdditionalPrivilegesUpdate,
TAiMcpActivityLogs,
TAiMcpActivityLogsInsert,
TAiMcpActivityLogsUpdate,
TAiMcpEndpoints,
TAiMcpEndpointServers,
TAiMcpEndpointServersInsert,
TAiMcpEndpointServersUpdate,
TAiMcpEndpointServerTools,
TAiMcpEndpointServerToolsInsert,
TAiMcpEndpointServerToolsUpdate,
TAiMcpEndpointsInsert,
TAiMcpEndpointsUpdate,
TAiMcpServers,
TAiMcpServersInsert,
TAiMcpServersUpdate,
TAiMcpServerTools,
TAiMcpServerToolsInsert,
TAiMcpServerToolsUpdate,
TAiMcpServerUserCredentials,
TAiMcpServerUserCredentialsInsert,
TAiMcpServerUserCredentialsUpdate,
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
@@ -1507,6 +1528,37 @@ declare module "knex/types/tables" {
TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate
>;
[TableName.AiMcpServer]: KnexOriginal.CompositeTableType<TAiMcpServers, TAiMcpServersInsert, TAiMcpServersUpdate>;
[TableName.AiMcpServerTool]: KnexOriginal.CompositeTableType<
TAiMcpServerTools,
TAiMcpServerToolsInsert,
TAiMcpServerToolsUpdate
>;
[TableName.AiMcpEndpoint]: KnexOriginal.CompositeTableType<
TAiMcpEndpoints,
TAiMcpEndpointsInsert,
TAiMcpEndpointsUpdate
>;
[TableName.AiMcpEndpointServer]: KnexOriginal.CompositeTableType<
TAiMcpEndpointServers,
TAiMcpEndpointServersInsert,
TAiMcpEndpointServersUpdate
>;
[TableName.AiMcpEndpointServerTool]: KnexOriginal.CompositeTableType<
TAiMcpEndpointServerTools,
TAiMcpEndpointServerToolsInsert,
TAiMcpEndpointServerToolsUpdate
>;
[TableName.AiMcpServerUserCredential]: KnexOriginal.CompositeTableType<
TAiMcpServerUserCredentials,
TAiMcpServerUserCredentialsInsert,
TAiMcpServerUserCredentialsUpdate
>;
[TableName.AiMcpActivityLog]: KnexOriginal.CompositeTableType<
TAiMcpActivityLogs,
TAiMcpActivityLogsInsert,
TAiMcpActivityLogsUpdate
>;
[TableName.ApprovalPolicies]: KnexOriginal.CompositeTableType<
TApprovalPolicies,
TApprovalPoliciesInsert,

View File

@@ -0,0 +1,122 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.AiMcpServer))) {
await knex.schema.createTable(TableName.AiMcpServer, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("url").notNullable();
t.text("description");
t.string("status");
t.string("credentialMode");
t.string("authMethod");
t.binary("encryptedCredentials");
t.binary("encryptedOauthConfig"); // Stores client ID/secret for OAuth servers without DCR
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.AiMcpServer);
}
if (!(await knex.schema.hasTable(TableName.AiMcpServerTool))) {
await knex.schema.createTable(TableName.AiMcpServerTool, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.text("description");
t.jsonb("inputSchema");
t.uuid("aiMcpServerId").notNullable();
t.foreign("aiMcpServerId").references("id").inTable(TableName.AiMcpServer).onDelete("CASCADE");
});
}
if (!(await knex.schema.hasTable(TableName.AiMcpEndpoint))) {
await knex.schema.createTable(TableName.AiMcpEndpoint, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.text("description");
t.string("status");
t.boolean("piiFiltering").defaultTo(false).notNullable();
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.AiMcpEndpoint);
}
if (!(await knex.schema.hasTable(TableName.AiMcpEndpointServer))) {
await knex.schema.createTable(TableName.AiMcpEndpointServer, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("aiMcpEndpointId").notNullable();
t.foreign("aiMcpEndpointId").references("id").inTable(TableName.AiMcpEndpoint).onDelete("CASCADE");
t.uuid("aiMcpServerId").notNullable();
t.foreign("aiMcpServerId").references("id").inTable(TableName.AiMcpServer).onDelete("CASCADE");
t.unique(["aiMcpEndpointId", "aiMcpServerId"]);
});
}
if (!(await knex.schema.hasTable(TableName.AiMcpEndpointServerTool))) {
await knex.schema.createTable(TableName.AiMcpEndpointServerTool, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("aiMcpEndpointId").notNullable();
t.foreign("aiMcpEndpointId").references("id").inTable(TableName.AiMcpEndpoint).onDelete("CASCADE");
t.uuid("aiMcpServerToolId").notNullable();
t.foreign("aiMcpServerToolId").references("id").inTable(TableName.AiMcpServerTool).onDelete("CASCADE");
t.boolean("isEnabled").defaultTo(false).notNullable();
t.unique(["aiMcpEndpointId", "aiMcpServerToolId"]);
});
}
// Store user OAuth credentials for MCP servers with "personal" credential mode
if (!(await knex.schema.hasTable(TableName.AiMcpServerUserCredential))) {
await knex.schema.createTable(TableName.AiMcpServerUserCredential, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.uuid("aiMcpServerId").notNullable();
t.foreign("aiMcpServerId").references("id").inTable(TableName.AiMcpServer).onDelete("CASCADE");
t.binary("encryptedCredentials").notNullable();
t.timestamps(true, true, true);
t.unique(["userId", "aiMcpServerId"]);
});
await createOnUpdateTrigger(knex, TableName.AiMcpServerUserCredential);
}
if (!(await knex.schema.hasTable(TableName.AiMcpActivityLog))) {
await knex.schema.createTable(TableName.AiMcpActivityLog, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.string("endpointName").notNullable();
t.string("serverName").notNullable();
t.string("toolName").notNullable();
t.string("actor").notNullable();
t.jsonb("request").notNullable();
t.jsonb("response").notNullable();
t.timestamps(true, true, true);
});
}
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.AiMcpServerUserCredential);
await knex.schema.dropTableIfExists(TableName.AiMcpServerUserCredential);
await knex.schema.dropTableIfExists(TableName.AiMcpEndpointServerTool);
await knex.schema.dropTableIfExists(TableName.AiMcpEndpointServer);
await dropOnUpdateTrigger(knex, TableName.AiMcpEndpoint);
await knex.schema.dropTableIfExists(TableName.AiMcpEndpoint);
await knex.schema.dropTableIfExists(TableName.AiMcpServerTool);
await dropOnUpdateTrigger(knex, TableName.AiMcpServer);
await knex.schema.dropTableIfExists(TableName.AiMcpServer);
await knex.schema.dropTableIfExists(TableName.AiMcpActivityLog);
}

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpActivityLogsSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
endpointName: z.string(),
serverName: z.string(),
toolName: z.string(),
actor: z.string(),
request: z.unknown(),
response: z.unknown(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TAiMcpActivityLogs = z.infer<typeof AiMcpActivityLogsSchema>;
export type TAiMcpActivityLogsInsert = Omit<z.input<typeof AiMcpActivityLogsSchema>, TImmutableDBKeys>;
export type TAiMcpActivityLogsUpdate = Partial<Omit<z.input<typeof AiMcpActivityLogsSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,21 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpEndpointServerToolsSchema = z.object({
id: z.string().uuid(),
aiMcpEndpointId: z.string().uuid(),
aiMcpServerToolId: z.string().uuid(),
isEnabled: z.boolean().default(false)
});
export type TAiMcpEndpointServerTools = z.infer<typeof AiMcpEndpointServerToolsSchema>;
export type TAiMcpEndpointServerToolsInsert = Omit<z.input<typeof AiMcpEndpointServerToolsSchema>, TImmutableDBKeys>;
export type TAiMcpEndpointServerToolsUpdate = Partial<
Omit<z.input<typeof AiMcpEndpointServerToolsSchema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,18 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpEndpointServersSchema = z.object({
id: z.string().uuid(),
aiMcpEndpointId: z.string().uuid(),
aiMcpServerId: z.string().uuid()
});
export type TAiMcpEndpointServers = z.infer<typeof AiMcpEndpointServersSchema>;
export type TAiMcpEndpointServersInsert = Omit<z.input<typeof AiMcpEndpointServersSchema>, TImmutableDBKeys>;
export type TAiMcpEndpointServersUpdate = Partial<Omit<z.input<typeof AiMcpEndpointServersSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpEndpointsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
status: z.string().nullable().optional(),
piiFiltering: z.boolean().default(false),
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TAiMcpEndpoints = z.infer<typeof AiMcpEndpointsSchema>;
export type TAiMcpEndpointsInsert = Omit<z.input<typeof AiMcpEndpointsSchema>, TImmutableDBKeys>;
export type TAiMcpEndpointsUpdate = Partial<Omit<z.input<typeof AiMcpEndpointsSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,20 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpServerToolsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
inputSchema: z.unknown().nullable().optional(),
aiMcpServerId: z.string().uuid()
});
export type TAiMcpServerTools = z.infer<typeof AiMcpServerToolsSchema>;
export type TAiMcpServerToolsInsert = Omit<z.input<typeof AiMcpServerToolsSchema>, TImmutableDBKeys>;
export type TAiMcpServerToolsUpdate = Partial<Omit<z.input<typeof AiMcpServerToolsSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,28 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpServerUserCredentialsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
aiMcpServerId: z.string().uuid(),
encryptedCredentials: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TAiMcpServerUserCredentials = z.infer<typeof AiMcpServerUserCredentialsSchema>;
export type TAiMcpServerUserCredentialsInsert = Omit<
z.input<typeof AiMcpServerUserCredentialsSchema>,
TImmutableDBKeys
>;
export type TAiMcpServerUserCredentialsUpdate = Partial<
Omit<z.input<typeof AiMcpServerUserCredentialsSchema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,29 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const AiMcpServersSchema = z.object({
id: z.string().uuid(),
name: z.string(),
url: z.string(),
description: z.string().nullable().optional(),
status: z.string().nullable().optional(),
credentialMode: z.string().nullable().optional(),
authMethod: z.string().nullable().optional(),
encryptedCredentials: zodBuffer.nullable().optional(),
encryptedOauthConfig: zodBuffer.nullable().optional(),
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TAiMcpServers = z.infer<typeof AiMcpServersSchema>;
export type TAiMcpServersInsert = Omit<z.input<typeof AiMcpServersSchema>, TImmutableDBKeys>;
export type TAiMcpServersUpdate = Partial<Omit<z.input<typeof AiMcpServersSchema>, TImmutableDBKeys>>;

View File

@@ -4,6 +4,13 @@ export * from "./access-approval-policies-bypassers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
export * from "./additional-privileges";
export * from "./ai-mcp-activity-logs";
export * from "./ai-mcp-endpoint-server-tools";
export * from "./ai-mcp-endpoint-servers";
export * from "./ai-mcp-endpoints";
export * from "./ai-mcp-server-tools";
export * from "./ai-mcp-server-user-credentials";
export * from "./ai-mcp-servers";
export * from "./api-keys";
export * from "./app-connections";
export * from "./approval-policies";

View File

@@ -226,6 +226,15 @@ export enum TableName {
PkiAcmeAuth = "pki_acme_auths",
PkiAcmeChallenge = "pki_acme_challenges",
// AI
AiMcpServer = "ai_mcp_servers",
AiMcpServerTool = "ai_mcp_server_tools",
AiMcpServerUserCredential = "ai_mcp_server_user_credentials",
AiMcpEndpoint = "ai_mcp_endpoints",
AiMcpEndpointServer = "ai_mcp_endpoint_servers",
AiMcpEndpointServerTool = "ai_mcp_endpoint_server_tools",
AiMcpActivityLog = "ai_mcp_activity_logs",
// Approval Policies
ApprovalPolicies = "approval_policies",
ApprovalPolicySteps = "approval_policy_steps",
@@ -327,7 +336,8 @@ export enum ProjectType {
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning",
PAM = "pam"
PAM = "pam",
AI = "ai"
}
export enum ActionProjectType {
@@ -337,6 +347,7 @@ export enum ActionProjectType {
SSH = ProjectType.SSH,
SecretScanning = ProjectType.SecretScanning,
PAM = ProjectType.PAM,
AI = ProjectType.AI,
// project operations that happen on all types
Any = "any"
}

View File

@@ -0,0 +1,104 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit } from "@app/server/config/rateLimiter";
const getMcpUrls = (siteUrl: string, endpointId: string) => {
// The MCP resource/connect URL
const resourceUrl = `${siteUrl}/api/v1/ai/mcp/endpoints/${endpointId}/connect`;
// The authorization server issuer (RFC 8414: metadata at /.well-known/oauth-authorization-server/{path})
const authServerIssuer = `${siteUrl}/mcp-endpoints/${endpointId}`;
// OAuth endpoint URLs
const apiBaseUrl = `${siteUrl}/api/v1/ai/mcp/endpoints/${endpointId}`;
const tokenEndpointUrl = `${apiBaseUrl}/oauth/token`;
const authorizeEndpointUrl = `${apiBaseUrl}/oauth/authorize`;
const registrationEndpointUrl = `${apiBaseUrl}/oauth/register`;
return {
resourceUrl,
authServerIssuer,
tokenEndpointUrl,
authorizeEndpointUrl,
registrationEndpointUrl
};
};
export const registerMcpEndpointMetadataRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
const siteUrl = removeTrailingSlash(appCfg.SITE_URL || "");
if (!siteUrl) {
return; // SITE_URL not configured, skip MCP endpoint metadata registration
}
const siteHost = new URL(siteUrl).host;
const scopeAccess = `https://${siteHost}/mcp:access`;
// OAuth 2.1: Protected Resource metadata
// GET /mcp-endpoints/:endpointId/.well-known/oauth-protected-resource
server.route({
method: "GET",
url: "/:endpointId/.well-known/oauth-protected-resource",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
endpointId: z.string().trim().min(1)
})
},
handler: async (req, reply) => {
const { resourceUrl, authServerIssuer } = getMcpUrls(siteUrl, req.params.endpointId);
return reply.send({
resource: resourceUrl,
authorization_servers: [authServerIssuer],
scopes_supported: ["openid", scopeAccess],
bearer_methods_supported: ["header"]
});
}
});
};
// RFC 8414 compliant OAuth Authorization Server metadata
// GET /.well-known/oauth-authorization-server/mcp-endpoints/:endpointId
export const registerMcpEndpointAuthServerMetadataRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
const siteUrl = removeTrailingSlash(appCfg.SITE_URL || "");
if (!siteUrl) {
return; // SITE_URL not configured, skip MCP auth server metadata registration
}
const siteHost = new URL(siteUrl).host;
const scopeAccess = `https://${siteHost}/mcp:access`;
server.route({
method: "GET",
url: "/mcp-endpoints/:endpointId",
schema: {
params: z.object({
endpointId: z.string().trim().min(1)
})
},
config: {
rateLimit: readLimit
},
handler: async (req, reply) => {
const { authServerIssuer, authorizeEndpointUrl, tokenEndpointUrl, registrationEndpointUrl } = getMcpUrls(
siteUrl,
req.params.endpointId
);
return reply.send({
issuer: authServerIssuer,
authorization_endpoint: authorizeEndpointUrl,
token_endpoint: tokenEndpointUrl,
registration_endpoint: registrationEndpointUrl,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["openid", scopeAccess]
});
}
});
};

View File

@@ -0,0 +1,70 @@
import { z } from "zod";
import { AiMcpActivityLogsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerAiMcpActivityLogRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
projectId: z.string().trim().min(1),
endpointName: z.string().optional(),
serverName: z.string().optional(),
toolName: z.string().optional(),
actor: z.string().optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
offset: z.coerce.number().default(0),
limit: z.coerce.number().max(100).default(20)
}),
response: {
200: z.object({
activityLogs: z.array(AiMcpActivityLogsSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { projectId, endpointName, serverName, toolName, actor, startDate, endDate, offset, limit } = req.query;
const activityLogs = await server.services.aiMcpActivityLog.listActivityLogs({
projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
filter: {
endpointName,
serverName,
toolName,
actor,
startDate,
endDate,
offset,
limit
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ACTIVITY_LOG_LIST,
metadata: {
count: activityLogs.length
}
}
});
return { activityLogs };
}
});
};

View File

@@ -0,0 +1,887 @@
import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from "fastify";
import { z } from "zod";
import { AiMcpEndpointServerToolsSchema } from "@app/db/schemas/ai-mcp-endpoint-server-tools";
import { AiMcpEndpointsSchema } from "@app/db/schemas/ai-mcp-endpoints";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
const sendWwwAuthenticate = (reply: FastifyReply, endpointId: string, description?: string) => {
const appCfg = getConfig();
const protectedResourceMetadataUrl = `${appCfg.SITE_URL}/mcp-endpoints/${endpointId}/.well-known/oauth-protected-resource`;
let header = `Bearer resource_metadata="${protectedResourceMetadataUrl}", scope="openid"`;
if (description) header = `${header}, error_description="${description}"`;
void reply.header("WWW-Authenticate", header);
};
// Custom onRequest hook to enforce auth while returning proper WWW-Authenticate hint for MCP clients
const requireMcpAuthHook = (
req: FastifyRequest,
reply: FastifyReply,
done: HookHandlerDoneFunction,
endpointId: string
) => {
const { auth } = req;
if (!auth) {
sendWwwAuthenticate(reply, endpointId, "Missing authorization header");
void reply.status(401).send();
return;
}
const allowed = auth.authMode === AuthMode.MCP_JWT;
if (!allowed) {
void reply.status(403).send();
return;
}
if (!req.permission.orgId) {
void reply.status(401).send({ message: "Unauthorized: organization context required" });
return;
}
done();
};
export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
})
},
url: "/:endpointId/connect",
onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId),
handler: async (req, res) => {
await res.hijack(); // allow manual control of the underlying res
if (req.auth.authMode === AuthMode.MCP_JWT && req.params.endpointId !== req.auth.token.mcp?.endpointId) {
throw new UnauthorizedError({ message: "Unauthorized" });
}
const {
server: mcpServer,
transport,
projectId,
endpointName
} = await server.services.aiMcpEndpoint.interactWithMcp({
endpointId: req.params.endpointId,
userId: req.permission.id,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_CONNECT,
metadata: {
endpointId: req.params.endpointId,
endpointName: endpointName || "",
userId: req.permission.id
}
}
});
// Close transport when client disconnects
res.raw.on("close", () => {
void transport.close().catch((err) => {
logger.error(err, "Failed to close transport for mcp endpoint");
});
});
await mcpServer.connect(transport);
await transport.handleRequest(req.raw, res.raw, req.body);
}
});
server.route({
method: ["GET", "DELETE"],
config: {
rateLimit: writeLimit
},
url: "/:endpointId/connect",
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
})
},
onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId),
handler: async (_req, res) => {
void res
.status(405)
.header("Allow", "POST")
.send({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Method not allowed"
},
id: null
});
}
});
server.route({
url: "/",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
projectId: z.string().uuid().trim().min(1),
name: z.string().trim().min(1).max(64),
description: z.string().trim().max(256).optional(),
serverIds: z.array(z.string().uuid()).default([])
}),
response: {
200: z.object({
endpoint: AiMcpEndpointsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const endpoint = await server.services.aiMcpEndpoint.createMcpEndpoint({
projectId: req.body.projectId,
name: req.body.name,
description: req.body.description,
serverIds: req.body.serverIds,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.body.projectId,
event: {
type: EventType.MCP_ENDPOINT_CREATE,
metadata: {
endpointId: endpoint.id,
name: endpoint.name,
description: req.body.description,
serverIds: req.body.serverIds
}
}
});
return { endpoint };
}
});
server.route({
url: "/",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
projectId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
endpoints: z.array(
AiMcpEndpointsSchema.extend({
connectedServers: z.number(),
activeTools: z.number(),
piiFiltering: z.boolean().optional()
})
),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const endpoints = await server.services.aiMcpEndpoint.listMcpEndpoints({
projectId: req.query.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.MCP_ENDPOINT_LIST,
metadata: {
count: endpoints.length
}
}
});
return {
endpoints,
totalCount: endpoints.length
};
}
});
server.route({
url: "/:endpointId",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
endpoint: AiMcpEndpointsSchema.extend({
connectedServers: z.number(),
activeTools: z.number(),
serverIds: z.array(z.string()),
piiFiltering: z.boolean().optional()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const endpoint = await server.services.aiMcpEndpoint.getMcpEndpointById({
endpointId: req.params.endpointId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: endpoint.projectId,
event: {
type: EventType.MCP_ENDPOINT_GET,
metadata: {
endpointId: endpoint.id,
name: endpoint.name
}
}
});
return { endpoint };
}
});
server.route({
url: "/:endpointId",
method: "PATCH",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
body: z.object({
name: z.string().trim().min(1).max(64).optional(),
description: z.string().trim().max(256).optional(),
serverIds: z.array(z.string().uuid()).optional(),
piiFiltering: z.boolean().optional()
}),
response: {
200: z.object({
endpoint: AiMcpEndpointsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const endpoint = await server.services.aiMcpEndpoint.updateMcpEndpoint({
endpointId: req.params.endpointId,
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: endpoint.projectId,
event: {
type: EventType.MCP_ENDPOINT_UPDATE,
metadata: {
endpointId: endpoint.id,
name: req.body.name,
description: req.body.description,
serverIds: req.body.serverIds,
piiFiltering: req.body.piiFiltering
}
}
});
return { endpoint };
}
});
server.route({
url: "/:endpointId",
method: "DELETE",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
endpoint: AiMcpEndpointsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const endpoint = await server.services.aiMcpEndpoint.deleteMcpEndpoint({
endpointId: req.params.endpointId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: endpoint.projectId,
event: {
type: EventType.MCP_ENDPOINT_DELETE,
metadata: {
endpointId: endpoint.id,
name: endpoint.name
}
}
});
return { endpoint };
}
});
server.route({
url: "/:endpointId/tools",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
tools: z.array(AiMcpEndpointServerToolsSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { tools, projectId, endpointName } = await server.services.aiMcpEndpoint.listEndpointTools({
endpointId: req.params.endpointId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_LIST_TOOLS,
metadata: {
endpointId: req.params.endpointId,
endpointName,
toolCount: tools.length
}
}
});
return { tools };
}
});
server.route({
url: "/:endpointId/tools/:serverToolId",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1),
serverToolId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
tool: AiMcpEndpointServerToolsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { tool, projectId, endpointName, toolName } = await server.services.aiMcpEndpoint.enableEndpointTool({
endpointId: req.params.endpointId,
serverToolId: req.params.serverToolId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_ENABLE_TOOL,
metadata: {
endpointId: req.params.endpointId,
endpointName,
serverToolId: req.params.serverToolId,
toolName
}
}
});
return { tool };
}
});
server.route({
url: "/:endpointId/tools/:serverToolId",
method: "DELETE",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1),
serverToolId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { projectId, endpointName, toolName } = await server.services.aiMcpEndpoint.disableEndpointTool({
endpointId: req.params.endpointId,
serverToolId: req.params.serverToolId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_DISABLE_TOOL,
metadata: {
endpointId: req.params.endpointId,
endpointName,
serverToolId: req.params.serverToolId,
toolName
}
}
});
return { message: "Tool disabled" };
}
});
server.route({
url: "/:endpointId/tools/bulk",
method: "PATCH",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
body: z.object({
tools: z.array(
z.object({
serverToolId: z.string().uuid(),
isEnabled: z.boolean()
})
)
}),
response: {
200: z.object({
tools: z.array(AiMcpEndpointServerToolsSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { tools, projectId, endpointName } = await server.services.aiMcpEndpoint.bulkUpdateEndpointTools({
endpointId: req.params.endpointId,
tools: req.body.tools,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_BULK_UPDATE_TOOLS,
metadata: {
endpointId: req.params.endpointId,
endpointName,
toolsUpdated: req.body.tools.length
}
}
});
return { tools };
}
});
// OAUTH 2.0
server.route({
method: "POST",
url: "/:endpointId/oauth/register",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
body: z.object({
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z.string(),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
client_name: z.string(),
client_uri: z.string().optional()
}),
response: {
200: z.object({
client_id: z.string(),
redirect_uris: z.array(z.string()),
client_name: z.string(),
client_uri: z.string().optional(),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.string(),
client_id_issued_at: z.number()
})
}
},
handler: async (req) => {
const payload = await server.services.aiMcpEndpoint.oauthRegisterClient({
endpointId: req.params.endpointId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: payload.projectId,
event: {
type: EventType.MCP_ENDPOINT_OAUTH_CLIENT_REGISTER,
metadata: {
endpointId: req.params.endpointId,
endpointName: payload.endpointName,
clientId: payload.client_id,
clientName: payload.client_name
}
}
});
return payload;
}
});
// OAuth authorize - redirect to scope selection page
server.route({
method: "GET",
url: "/:endpointId/oauth/authorize",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
querystring: z.object({
response_type: z.string(),
client_id: z.string(),
code_challenge: z.string(),
code_challenge_method: z.enum(["S256"]),
redirect_uri: z.string(),
resource: z.string(),
state: z.string().optional()
})
},
handler: async (req, res) => {
await server.services.aiMcpEndpoint.oauthAuthorizeClient({
clientId: req.query.client_id,
state: req.query.state
});
const query = new URLSearchParams({
...req.query,
endpointId: req.params.endpointId
}).toString();
void res.redirect(`/organization/mcp-endpoint-finalize?${query}`);
}
});
server.route({
method: "POST",
url: "/:endpointId/oauth/finalize",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
body: z.object({
response_type: z.string(),
client_id: z.string(),
code_challenge: z.string(),
code_challenge_method: z.enum(["S256"]),
redirect_uri: z.string(),
resource: z.string(),
expireIn: z.string().refine((val) => ms(val) > 0, "Max TTL must be a positive number")
}),
response: {
200: z.object({
callbackUrl: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const userInfo = req.auth.authMode === AuthMode.JWT ? req.auth.user : null;
if (!userInfo) throw new BadRequestError({ message: "User info not found" });
const {
url: redirectUri,
projectId,
endpointName,
clientId
} = await server.services.aiMcpEndpoint.oauthFinalize({
endpointId: req.params.endpointId,
clientId: req.body.client_id,
codeChallenge: req.body.code_challenge,
codeChallengeMethod: req.body.code_challenge_method,
redirectUri: req.body.redirect_uri,
resource: req.body.resource,
responseType: req.body.response_type,
tokenId: req.auth.authMode === AuthMode.JWT ? req.auth.tokenVersionId : "",
userInfo,
expiry: req.body.expireIn,
permission: req.permission,
userAgent: req.auditLogInfo.userAgent || "",
userIp: req.auditLogInfo.ipAddress || ""
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_OAUTH_AUTHORIZE,
metadata: {
endpointId: req.params.endpointId,
endpointName,
clientId
}
}
});
return { callbackUrl: redirectUri.toString() };
}
});
server.route({
method: "POST",
url: "/:endpointId/oauth/token",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
body: z.object({
grant_type: z.literal("authorization_code"),
code: z.string(),
redirect_uri: z.string().url(),
code_verifier: z.string(),
client_id: z.string()
}),
response: {
200: z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
scope: z.string()
})
}
},
handler: async (req) => {
const payload = await server.services.aiMcpEndpoint.oauthTokenExchange({
endpointId: req.params.endpointId,
...req.body
});
return payload;
}
});
// Get servers requiring personal authentication
server.route({
method: "GET",
url: "/:endpointId/servers-requiring-auth",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
servers: z.array(
z.object({
id: z.string(),
name: z.string(),
url: z.string(),
hasCredentials: z.boolean()
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const servers = await server.services.aiMcpEndpoint.getServersRequiringAuth({
endpointId: req.params.endpointId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return { servers };
}
});
// Initiate OAuth for a server (personal credential mode)
server.route({
method: "POST",
url: "/:endpointId/servers/:serverId/oauth/initiate",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1),
serverId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
authUrl: z.string(),
sessionId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.aiMcpEndpoint.initiateServerOAuth({
endpointId: req.params.endpointId,
serverId: req.params.serverId,
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return result;
}
});
// Save user credentials after OAuth completes
server.route({
method: "POST",
url: "/:endpointId/servers/:serverId/credentials",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
endpointId: z.string().uuid().trim().min(1),
serverId: z.string().uuid().trim().min(1)
}),
body: z.object({
accessToken: z.string().min(1),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
tokenType: z.string().optional()
}),
response: {
200: z.object({
success: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { success, projectId, endpointName, serverName } =
await server.services.aiMcpEndpoint.saveUserServerCredential({
endpointId: req.params.endpointId,
serverId: req.params.serverId,
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_ENDPOINT_SAVE_USER_CREDENTIAL,
metadata: {
endpointId: req.params.endpointId,
endpointName,
serverId: req.params.serverId,
serverName
}
}
});
return { success };
}
});
};

View File

@@ -0,0 +1,473 @@
import { z } from "zod";
import { AiMcpServerToolsSchema } from "@app/db/schemas/ai-mcp-server-tools";
import { AiMcpServersSchema } from "@app/db/schemas/ai-mcp-servers";
import { AiMcpServerAuthMethod, AiMcpServerCredentialMode } from "@app/ee/services/ai-mcp-server/ai-mcp-server-enum";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Common fields for MCP server creation
const CreateMcpServerBaseSchema = z.object({
projectId: z.string().uuid().trim().min(1),
name: z.string().trim().min(1).max(64),
url: z.string().trim().url(),
description: z.string().trim().max(256).optional(),
credentialMode: z.nativeEnum(AiMcpServerCredentialMode),
oauthClientId: z.string().trim().min(1).optional(),
oauthClientSecret: z.string().trim().min(1).optional()
});
const McpServerCredentialsSchema = z.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(AiMcpServerAuthMethod.BASIC),
credentials: z.object({
username: z.string().min(1),
password: z.string().min(1)
})
}),
z.object({
authMethod: z.literal(AiMcpServerAuthMethod.BEARER),
credentials: z.object({
token: z.string().min(1)
})
}),
z.object({
authMethod: z.literal(AiMcpServerAuthMethod.OAUTH),
credentials: z.object({
accessToken: z.string().min(1),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
tokenType: z.string().optional()
})
})
]);
export const registerAiMcpServerRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/oauth/initiate",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
projectId: z.string().uuid().trim().min(1),
url: z.string().trim().url(),
clientId: z.string().trim().min(1).optional(),
clientSecret: z.string().trim().min(1).optional()
}),
response: {
200: z.object({
authUrl: z.string(),
sessionId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.aiMcpServer.initiateOAuth({
projectId: req.body.projectId,
url: req.body.url,
clientId: req.body.clientId,
clientSecret: req.body.clientSecret,
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return result;
}
});
// OAuth: Callback (redirect from MCP server)
server.route({
url: "/oauth/callback",
method: "GET",
config: {
rateLimit: writeLimit
},
schema: {
querystring: z.object({
code: z.string(),
state: z.string() // session ID
})
},
handler: async (req, res) => {
const { code, state: sessionId } = req.query;
try {
await server.services.aiMcpServer.handleOAuthCallback({
sessionId,
code
});
// Return HTML that closes the popup immediately
return await res.type("text/html").send(`
<!DOCTYPE html>
<html>
<head><title>OAuth Complete</title></head>
<body>
<script>window.close();</script>
</body>
</html>
`);
} catch {
// Return error HTML that closes immediately
return res.type("text/html").send(`
<!DOCTYPE html>
<html>
<head><title>OAuth Error</title></head>
<body>
<script>window.close();</script>
</body>
</html>
`);
}
}
});
server.route({
url: "/oauth/status/:sessionId",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
sessionId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
authorized: z.boolean(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
tokenType: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.aiMcpServer.getOAuthStatus({
sessionId: req.params.sessionId,
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return result;
}
});
// Create MCP Server
server.route({
url: "/",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
body: CreateMcpServerBaseSchema.and(McpServerCredentialsSchema),
response: {
200: z.object({
server: AiMcpServersSchema.omit({ encryptedCredentials: true, encryptedOauthConfig: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mcpServer = await server.services.aiMcpServer.createMcpServer({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.body.projectId,
event: {
type: EventType.MCP_SERVER_CREATE,
metadata: {
serverId: mcpServer.id,
name: mcpServer.name,
url: mcpServer.url,
credentialMode: req.body.credentialMode,
authMethod: req.body.authMethod
}
}
});
return { server: mcpServer };
}
});
server.route({
url: "/",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
projectId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
servers: z.array(AiMcpServersSchema.omit({ encryptedCredentials: true, encryptedOauthConfig: true })),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mcpServers = await server.services.aiMcpServer.listMcpServers({
projectId: req.query.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.MCP_SERVER_LIST,
metadata: {
count: mcpServers.length
}
}
});
return {
servers: mcpServers,
totalCount: mcpServers.length
};
}
});
server.route({
url: "/:serverId",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
serverId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
server: AiMcpServersSchema.omit({ encryptedCredentials: true, encryptedOauthConfig: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mcpServer = await server.services.aiMcpServer.getMcpServerById({
serverId: req.params.serverId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: mcpServer.projectId,
event: {
type: EventType.MCP_SERVER_GET,
metadata: {
serverId: mcpServer.id,
name: mcpServer.name
}
}
});
return { server: mcpServer };
}
});
server.route({
url: "/:serverId",
method: "PATCH",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
serverId: z.string().uuid().trim().min(1)
}),
body: z.object({
name: z.string().trim().min(1).max(64).optional(),
description: z.string().trim().max(256).optional()
}),
response: {
200: z.object({
server: AiMcpServersSchema.omit({ encryptedCredentials: true, encryptedOauthConfig: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mcpServer = await server.services.aiMcpServer.updateMcpServer({
serverId: req.params.serverId,
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: mcpServer.projectId,
event: {
type: EventType.MCP_SERVER_UPDATE,
metadata: {
serverId: mcpServer.id,
name: mcpServer.name
}
}
});
return { server: mcpServer };
}
});
server.route({
url: "/:serverId/tools",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
serverId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
tools: z.array(AiMcpServerToolsSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { tools, projectId, serverName } = await server.services.aiMcpServer.listMcpServerTools({
serverId: req.params.serverId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_SERVER_LIST_TOOLS,
metadata: {
serverId: req.params.serverId,
serverName,
toolCount: tools.length
}
}
});
return { tools };
}
});
server.route({
url: "/:serverId/tools/sync",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
serverId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
tools: z.array(AiMcpServerToolsSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { tools, projectId, serverName } = await server.services.aiMcpServer.syncMcpServerTools({
serverId: req.params.serverId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.MCP_SERVER_SYNC_TOOLS,
metadata: {
serverId: req.params.serverId,
serverName,
toolCount: tools.length
}
}
});
return { tools };
}
});
server.route({
url: "/:serverId",
method: "DELETE",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
serverId: z.string().uuid().trim().min(1)
}),
response: {
200: z.object({
server: AiMcpServersSchema.omit({ encryptedCredentials: true, encryptedOauthConfig: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mcpServer = await server.services.aiMcpServer.deleteMcpServer({
serverId: req.params.serverId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: mcpServer.projectId,
event: {
type: EventType.MCP_SERVER_DELETE,
metadata: {
serverId: mcpServer.id,
name: mcpServer.name
}
}
});
return { server: mcpServer };
}
});
};

View File

@@ -2,6 +2,9 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
import { registerAiMcpActivityLogRouter } from "./ai-mcp-activity-log-router";
import { registerAiMcpEndpointRouter } from "./ai-mcp-endpoint-router";
import { registerAiMcpServerRouter } from "./ai-mcp-server-router";
import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers";
import { registerCaCrlRouter } from "./certificate-authority-crl-router";
@@ -223,4 +226,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
},
{ prefix: "/pam" }
);
await server.register(
async (aiRouter) => {
await aiRouter.register(registerAiMcpServerRouter, { prefix: "/mcp/servers" });
await aiRouter.register(registerAiMcpEndpointRouter, { prefix: "/mcp/endpoints" });
await aiRouter.register(registerAiMcpActivityLogRouter, { prefix: "/mcp/activity-logs" });
},
{ prefix: "/ai" }
);
};

View File

@@ -0,0 +1,86 @@
import knex from "knex";
import { TDbClient } from "@app/db";
import { TableName, TAiMcpActivityLogs } from "@app/db/schemas";
import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors";
import { ormify, selectAllTableCols, TOrmify } from "@app/lib/knex";
export type TFindActivityLogsQuery = {
projectId: string;
endpointName?: string;
serverName?: string;
toolName?: string;
actor?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
};
export interface TAiMcpActivityLogDALFactory extends Omit<TOrmify<TableName.AiMcpActivityLog>, "find"> {
find: (arg: TFindActivityLogsQuery, tx?: knex.Knex) => Promise<TAiMcpActivityLogs[]>;
}
export const aiMcpActivityLogDALFactory = (db: TDbClient): TAiMcpActivityLogDALFactory => {
const aiMcpActivityLogOrm = ormify(db, TableName.AiMcpActivityLog);
const find: TAiMcpActivityLogDALFactory["find"] = async (
{ projectId, endpointName, serverName, toolName, actor, startDate, endDate, limit = 20, offset = 0 },
tx
) => {
try {
const sqlQuery = (tx || db.replicaNode())(TableName.AiMcpActivityLog).where(
`${TableName.AiMcpActivityLog}.projectId`,
projectId
);
// Apply date filters if provided
if (startDate) {
void sqlQuery.whereRaw(`"${TableName.AiMcpActivityLog}"."createdAt" >= ?::timestamptz`, [startDate]);
}
if (endDate) {
void sqlQuery.andWhereRaw(`"${TableName.AiMcpActivityLog}"."createdAt" < ?::timestamptz`, [endDate]);
}
// Apply exact filters
if (endpointName) {
void sqlQuery.where(`${TableName.AiMcpActivityLog}.endpointName`, endpointName);
}
if (serverName) {
void sqlQuery.where(`${TableName.AiMcpActivityLog}.serverName`, serverName);
}
if (toolName) {
void sqlQuery.where(`${TableName.AiMcpActivityLog}.toolName`, toolName);
}
if (actor) {
void sqlQuery.where(`${TableName.AiMcpActivityLog}.actor`, actor);
}
// Apply pagination and ordering
void sqlQuery
.select(selectAllTableCols(TableName.AiMcpActivityLog))
.limit(limit)
.offset(offset)
.orderBy(`${TableName.AiMcpActivityLog}.createdAt`, "desc");
// Timeout long running queries to prevent DB resource issues (2 minutes)
const docs = await sqlQuery.timeout(1000 * 120);
return docs;
} catch (error) {
if (error instanceof knex.KnexTimeoutError) {
throw new GatewayTimeoutError({
error,
message: "Failed to fetch MCP activity logs due to timeout. Add more search filters."
});
}
throw new DatabaseError({ error });
}
};
return { ...aiMcpActivityLogOrm, find };
};

View File

@@ -0,0 +1,71 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TAiMcpActivityLogsInsert } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TAiMcpActivityLogDALFactory, TFindActivityLogsQuery } from "./ai-mcp-activity-log-dal";
export type TAiMcpActivityLogServiceFactory = ReturnType<typeof aiMcpActivityLogServiceFactory>;
export type TAiMcpActivityLogServiceFactoryDep = {
aiMcpActivityLogDAL: TAiMcpActivityLogDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TListActivityLogsFilter = {
endpointName?: string;
serverName?: string;
toolName?: string;
actor?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
};
export type TListActivityLogsDTO = TProjectPermission & {
filter?: TListActivityLogsFilter;
};
export const aiMcpActivityLogServiceFactory = ({
aiMcpActivityLogDAL,
permissionService
}: TAiMcpActivityLogServiceFactoryDep) => {
const createActivityLog = async (activityLog: TAiMcpActivityLogsInsert) => {
return aiMcpActivityLogDAL.create(activityLog);
};
const listActivityLogs = async ({
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
filter
}: TListActivityLogsDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.AI
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.McpActivityLogs);
const query: TFindActivityLogsQuery = {
projectId,
...filter
};
return aiMcpActivityLogDAL.find(query);
};
return {
createActivityLog,
listActivityLogs
};
};

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpEndpointDALFactory = ReturnType<typeof aiMcpEndpointDALFactory>;
export const aiMcpEndpointDALFactory = (db: TDbClient) => {
const aiMcpEndpointOrm = ormify(db, TableName.AiMcpEndpoint);
return aiMcpEndpointOrm;
};

View File

@@ -0,0 +1,4 @@
export enum AiMcpEndpointStatus {
ACTIVE = "active",
DISABLED = "disabled"
}

View File

@@ -0,0 +1,16 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpEndpointServerDALFactory = ReturnType<typeof aiMcpEndpointServerDALFactory>;
export const aiMcpEndpointServerDALFactory = (db: TDbClient) => {
const aiMcpEndpointServerOrm = ormify(db, TableName.AiMcpEndpointServer);
const countByEndpointId = async (aiMcpEndpointId: string) => {
const result = await db.replicaNode()(TableName.AiMcpEndpointServer).where({ aiMcpEndpointId }).count().first();
return Number(result?.count ?? 0);
};
return { ...aiMcpEndpointServerOrm, countByEndpointId };
};

View File

@@ -0,0 +1,16 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpEndpointServerToolDALFactory = ReturnType<typeof aiMcpEndpointServerToolDALFactory>;
export const aiMcpEndpointServerToolDALFactory = (db: TDbClient) => {
const aiMcpEndpointServerToolOrm = ormify(db, TableName.AiMcpEndpointServerTool);
const countByEndpointId = async (aiMcpEndpointId: string) => {
const result = await db.replicaNode()(TableName.AiMcpEndpointServerTool).where({ aiMcpEndpointId }).count().first();
return Number(result?.count ?? 0);
};
return { ...aiMcpEndpointServerToolOrm, countByEndpointId };
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateAiMcpEndpointDTO = {
name: string;
description?: string;
serverIds?: string[];
} & TProjectPermission;
export type TUpdateAiMcpEndpointDTO = {
endpointId: string;
name?: string;
description?: string;
serverIds?: string[];
piiFiltering?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAiMcpEndpointDTO = {
endpointId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetAiMcpEndpointDTO = {
endpointId: string;
} & Omit<TProjectPermission, "projectId">;
export type TInteractWithMcpDTO = {
endpointId: string;
userId: string;
} & Omit<TProjectPermission, "projectId">;
export type TListAiMcpEndpointsDTO = TProjectPermission;
export type TAiMcpEndpointWithServers = {
id: string;
name: string;
description?: string | null;
status?: string | null;
piiFiltering?: boolean;
projectId: string;
createdAt: Date;
updatedAt: Date;
connectedServers: number;
activeTools: number;
};
export type TListEndpointToolsDTO = {
endpointId: string;
} & Omit<TProjectPermission, "projectId">;
export type TEnableEndpointToolDTO = {
endpointId: string;
serverToolId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDisableEndpointToolDTO = {
endpointId: string;
serverToolId: string;
} & Omit<TProjectPermission, "projectId">;
export type TBulkUpdateEndpointToolsDTO = {
endpointId: string;
tools: Array<{
serverToolId: string;
isEnabled: boolean;
}>;
} & Omit<TProjectPermission, "projectId">;
export type TEndpointToolConfig = {
id: string;
aiMcpEndpointId: string;
aiMcpServerToolId: string;
isEnabled: boolean;
};
// OAuth 2.0 Types
export type TOAuthRegisterClientDTO = {
endpointId: string;
redirect_uris: string[];
token_endpoint_auth_method: string;
grant_types: string[];
response_types: string[];
client_name: string;
client_uri?: string;
};
export type TOAuthAuthorizeClientDTO = {
clientId: string;
state?: string;
};
export type TOAuthFinalizeDTO = {
endpointId: string;
clientId: string;
codeChallenge: string;
codeChallengeMethod: string;
redirectUri: string;
resource: string;
responseType: string;
path?: string;
expiry: string;
tokenId: string;
userInfo: {
id: string;
email?: string | null;
firstName?: string | null;
lastName?: string | null;
};
permission: {
type: string;
id: string;
orgId: string;
authMethod: string | null;
};
userAgent: string;
userIp: string;
};
export type TOAuthTokenExchangeDTO = {
endpointId: string;
grant_type: "authorization_code";
code: string;
redirect_uri: string;
code_verifier: string;
client_id: string;
};
// Personal credentials types
export type TGetServersRequiringAuthDTO = {
endpointId: string;
} & Omit<TProjectPermission, "projectId">;
export type TServerAuthStatus = {
id: string;
name: string;
url: string;
hasCredentials: boolean;
oauthClientId?: string;
oauthClientSecret?: string;
};
export type TInitiateServerOAuthDTO = {
endpointId: string;
serverId: string;
} & Omit<TProjectPermission, "projectId">;
export type TSaveUserServerCredentialDTO = {
endpointId: string;
serverId: string;
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpServerDALFactory = ReturnType<typeof aiMcpServerDALFactory>;
export const aiMcpServerDALFactory = (db: TDbClient) => {
const aiMcpServerOrm = ormify(db, TableName.AiMcpServer);
return aiMcpServerOrm;
};

View File

@@ -0,0 +1,16 @@
export enum AiMcpServerCredentialMode {
SHARED = "shared",
PERSONAL = "personal"
}
export enum AiMcpServerAuthMethod {
BASIC = "basic",
BEARER = "bearer",
OAUTH = "oauth"
}
export enum AiMcpServerStatus {
ACTIVE = "active",
DISABLED = "disabled",
UNINITIALIZED = "uninitialized"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpServerToolDALFactory = ReturnType<typeof aiMcpServerToolDALFactory>;
export const aiMcpServerToolDALFactory = (db: TDbClient) => {
const aiMcpServerToolOrm = ormify(db, TableName.AiMcpServerTool);
return aiMcpServerToolOrm;
};

View File

@@ -0,0 +1,140 @@
import { TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod } from "@app/services/auth/auth-type";
import { AiMcpServerAuthMethod, AiMcpServerCredentialMode } from "./ai-mcp-server-enum";
// OAuth types from MCP server
// Protected Resource Metadata (RFC 9728)
export type TOAuthProtectedResourceMetadata = {
resource: string;
resource_name?: string;
authorization_servers: string[];
bearer_methods_supported?: string[];
scopes_supported?: string[];
};
// Authorization Server Metadata (RFC 8414)
export type TOAuthAuthorizationServerMetadata = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string; // Optional - not all servers support DCR
scopes_supported?: string[];
response_types_supported?: string[];
grant_types_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
code_challenge_methods_supported?: string[];
};
export type TOAuthDynamicClientMetadata = {
client_id: string;
client_secret?: string;
client_id_issued_at?: number;
client_secret_expires_at?: number;
redirect_uris: string[];
token_endpoint_auth_method: string;
grant_types: string[];
response_types: string[];
client_name?: string;
};
export type TOAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
};
// Credential types
export type TBasicCredentials = {
username: string;
password: string;
};
export type TBearerCredentials = {
token: string;
};
export type TOAuthCredentials = {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
};
export type TAiMcpServerCredentials = TBasicCredentials | TBearerCredentials | TOAuthCredentials;
// OAuth session stored in keystore
export type TOAuthSession = {
actorId: string;
codeVerifier: string;
codeChallenge: string;
clientId: string;
clientSecret?: string; // For servers that don't support DCR (like GitHub)
projectId: string;
serverUrl: string;
redirectUri: string;
tokenEndpoint: string; // Stored from OAuth discovery for token exchange
// Set after callback
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
authorized?: boolean;
};
export type TInitiateOAuthDTO = {
projectId: string;
url: string;
actorId: string;
clientId?: string;
clientSecret?: string;
actor?: string;
actorAuthMethod?: ActorAuthMethod;
actorOrgId?: string;
};
export type THandleOAuthCallbackDTO = {
sessionId: string;
code: string;
};
export type TGetOAuthStatusDTO = {
sessionId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateAiMcpServerDTO = {
name: string;
url: string;
description?: string;
credentialMode: AiMcpServerCredentialMode;
authMethod: AiMcpServerAuthMethod;
credentials: TBasicCredentials | TBearerCredentials | TOAuthCredentials;
oauthClientId?: string;
oauthClientSecret?: string;
} & TProjectPermission;
export type TListMcpServersDTO = TProjectPermission;
export type TGetMcpServerByIdDTO = {
serverId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAiMcpServerDTO = {
serverId: string;
name?: string;
description?: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteMcpServerDTO = {
serverId: string;
} & Omit<TProjectPermission, "projectId">;
export type TListMcpServerToolsDTO = {
serverId: string;
} & Omit<TProjectPermission, "projectId">;
export type TSyncMcpServerToolsDTO = {
serverId: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -0,0 +1,26 @@
import { TDbClient } from "@app/db";
import { TableName, TAiMcpServerUserCredentialsInsert } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAiMcpServerUserCredentialDALFactory = ReturnType<typeof aiMcpServerUserCredentialDALFactory>;
export const aiMcpServerUserCredentialDALFactory = (db: TDbClient) => {
const aiMcpServerUserCredentialOrm = ormify(db, TableName.AiMcpServerUserCredential);
const findByUserAndServer = async (userId: string, aiMcpServerId: string) => {
return aiMcpServerUserCredentialOrm.findOne({ userId, aiMcpServerId });
};
const upsertCredential = async (data: TAiMcpServerUserCredentialsInsert) => {
const [result] = await aiMcpServerUserCredentialOrm.upsert([data], ["userId", "aiMcpServerId"], undefined, [
"encryptedCredentials"
]);
return result;
};
return {
...aiMcpServerUserCredentialOrm,
findByUserAndServer,
upsertCredential
};
};

View File

@@ -591,6 +591,33 @@ export enum EventType {
ATTEMPT_ACME_CHALLENGE = "attempt-acme-challenge",
FAIL_ACME_CHALLENGE = "fail-acme-challenge",
// MCP Endpoints
MCP_ENDPOINT_CREATE = "mcp-endpoint-create",
MCP_ENDPOINT_UPDATE = "mcp-endpoint-update",
MCP_ENDPOINT_DELETE = "mcp-endpoint-delete",
MCP_ENDPOINT_GET = "mcp-endpoint-get",
MCP_ENDPOINT_LIST = "mcp-endpoint-list",
MCP_ENDPOINT_LIST_TOOLS = "mcp-endpoint-list-tools",
MCP_ENDPOINT_ENABLE_TOOL = "mcp-endpoint-enable-tool",
MCP_ENDPOINT_DISABLE_TOOL = "mcp-endpoint-disable-tool",
MCP_ENDPOINT_BULK_UPDATE_TOOLS = "mcp-endpoint-bulk-update-tools",
MCP_ENDPOINT_OAUTH_CLIENT_REGISTER = "mcp-endpoint-oauth-client-register",
MCP_ENDPOINT_OAUTH_AUTHORIZE = "mcp-endpoint-oauth-authorize",
MCP_ENDPOINT_CONNECT = "mcp-endpoint-connect",
MCP_ENDPOINT_SAVE_USER_CREDENTIAL = "mcp-endpoint-save-user-credential",
// MCP Servers
MCP_SERVER_CREATE = "mcp-server-create",
MCP_SERVER_UPDATE = "mcp-server-update",
MCP_SERVER_DELETE = "mcp-server-delete",
MCP_SERVER_GET = "mcp-server-get",
MCP_SERVER_LIST = "mcp-server-list",
MCP_SERVER_LIST_TOOLS = "mcp-server-list-tools",
MCP_SERVER_SYNC_TOOLS = "mcp-server-sync-tools",
// MCP Activity Logs
MCP_ACTIVITY_LOG_LIST = "mcp-activity-log-list",
// Dynamic Secrets
CREATE_DYNAMIC_SECRET = "create-dynamic-secret",
UPDATE_DYNAMIC_SECRET = "update-dynamic-secret",
@@ -4514,6 +4541,193 @@ interface FailAcmeChallengeEvent {
};
}
interface McpEndpointCreateEvent {
type: EventType.MCP_ENDPOINT_CREATE;
metadata: {
endpointId: string;
name: string;
description?: string;
serverIds: string[];
};
}
interface McpEndpointUpdateEvent {
type: EventType.MCP_ENDPOINT_UPDATE;
metadata: {
endpointId: string;
name?: string;
description?: string;
serverIds?: string[];
piiFiltering?: boolean;
};
}
interface McpEndpointDeleteEvent {
type: EventType.MCP_ENDPOINT_DELETE;
metadata: {
endpointId: string;
name: string;
};
}
interface McpEndpointGetEvent {
type: EventType.MCP_ENDPOINT_GET;
metadata: {
endpointId: string;
name: string;
};
}
interface McpEndpointListEvent {
type: EventType.MCP_ENDPOINT_LIST;
metadata: {
count: number;
};
}
interface McpEndpointListToolsEvent {
type: EventType.MCP_ENDPOINT_LIST_TOOLS;
metadata: {
endpointId: string;
endpointName: string;
toolCount: number;
};
}
interface McpEndpointEnableToolEvent {
type: EventType.MCP_ENDPOINT_ENABLE_TOOL;
metadata: {
endpointId: string;
endpointName: string;
serverToolId: string;
toolName: string;
};
}
interface McpEndpointDisableToolEvent {
type: EventType.MCP_ENDPOINT_DISABLE_TOOL;
metadata: {
endpointId: string;
endpointName: string;
serverToolId: string;
toolName: string;
};
}
interface McpEndpointBulkUpdateToolsEvent {
type: EventType.MCP_ENDPOINT_BULK_UPDATE_TOOLS;
metadata: {
endpointId: string;
endpointName: string;
toolsUpdated: number;
};
}
interface McpEndpointOAuthClientRegisterEvent {
type: EventType.MCP_ENDPOINT_OAUTH_CLIENT_REGISTER;
metadata: {
endpointId: string;
endpointName: string;
clientId: string;
clientName: string;
};
}
interface McpEndpointOAuthAuthorizeEvent {
type: EventType.MCP_ENDPOINT_OAUTH_AUTHORIZE;
metadata: {
endpointId: string;
endpointName: string;
clientId: string;
};
}
interface McpEndpointConnectEvent {
type: EventType.MCP_ENDPOINT_CONNECT;
metadata: {
endpointId: string;
endpointName: string;
userId: string;
};
}
interface McpEndpointSaveUserCredentialEvent {
type: EventType.MCP_ENDPOINT_SAVE_USER_CREDENTIAL;
metadata: {
endpointId: string;
endpointName: string;
serverId: string;
serverName: string;
};
}
interface McpServerCreateEvent {
type: EventType.MCP_SERVER_CREATE;
metadata: {
serverId: string;
name: string;
url: string;
credentialMode: string;
authMethod: string;
};
}
interface McpServerUpdateEvent {
type: EventType.MCP_SERVER_UPDATE;
metadata: {
serverId: string;
name: string;
};
}
interface McpServerDeleteEvent {
type: EventType.MCP_SERVER_DELETE;
metadata: {
serverId: string;
name: string;
};
}
interface McpServerGetEvent {
type: EventType.MCP_SERVER_GET;
metadata: {
serverId: string;
name: string;
};
}
interface McpServerListEvent {
type: EventType.MCP_SERVER_LIST;
metadata: {
count: number;
};
}
interface McpServerListToolsEvent {
type: EventType.MCP_SERVER_LIST_TOOLS;
metadata: {
serverId: string;
serverName: string;
toolCount: number;
};
}
interface McpServerSyncToolsEvent {
type: EventType.MCP_SERVER_SYNC_TOOLS;
metadata: {
serverId: string;
serverName: string;
toolCount: number;
};
}
interface McpActivityLogListEvent {
type: EventType.MCP_ACTIVITY_LOG_LIST;
metadata: {
count: number;
};
}
interface GetDynamicSecretLeaseEvent {
type: EventType.GET_DYNAMIC_SECRET_LEASE;
metadata: {
@@ -5076,6 +5290,27 @@ export type Event =
| PassedAcmeChallengeEvent
| AttemptAcmeChallengeEvent
| FailAcmeChallengeEvent
| McpEndpointCreateEvent
| McpEndpointUpdateEvent
| McpEndpointDeleteEvent
| McpEndpointGetEvent
| McpEndpointListEvent
| McpEndpointListToolsEvent
| McpEndpointEnableToolEvent
| McpEndpointDisableToolEvent
| McpEndpointBulkUpdateToolsEvent
| McpEndpointOAuthClientRegisterEvent
| McpEndpointOAuthAuthorizeEvent
| McpEndpointConnectEvent
| McpEndpointSaveUserCredentialEvent
| McpServerCreateEvent
| McpServerUpdateEvent
| McpServerDeleteEvent
| McpServerGetEvent
| McpServerListEvent
| McpServerListToolsEvent
| McpServerSyncToolsEvent
| McpActivityLogListEvent
| CreateDynamicSecretEvent
| UpdateDynamicSecretEvent
| DeleteDynamicSecretEvent

View File

@@ -113,7 +113,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
eventSubscriptions: false,
machineIdentityAuthTemplates: false,
pkiLegacyTemplates: false,
pam: false
pam: false,
ai: false
});
export const setupLicenseRequestWithStore = (

View File

@@ -93,6 +93,7 @@ export type TFeatureSet = {
fips: false;
eventSubscriptions: false;
pam: false;
ai: false;
};
export type TOrgPlansTableDTO = {

View File

@@ -28,6 +28,7 @@ import {
ProjectPermissionSecretScanningDataSourceActions,
ProjectPermissionSecretScanningFindingActions,
ProjectPermissionSecretSyncActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSet,
ProjectPermissionSshHostActions,
ProjectPermissionSub
@@ -55,7 +56,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.PamFolders,
ProjectPermissionSub.PamResources
ProjectPermissionSub.PamResources,
ProjectPermissionSub.McpServers,
ProjectPermissionSub.McpActivityLogs
].forEach((el) => {
can(
[
@@ -341,6 +344,17 @@ const buildAdminPermissionRules = () => {
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
can(
[
ProjectPermissionMcpEndpointActions.Read,
ProjectPermissionMcpEndpointActions.Connect,
ProjectPermissionMcpEndpointActions.Create,
ProjectPermissionMcpEndpointActions.Edit,
ProjectPermissionMcpEndpointActions.Delete
],
ProjectPermissionSub.McpEndpoints
);
can(
[ProjectPermissionApprovalRequestActions.Read, ProjectPermissionApprovalRequestActions.Create],
ProjectPermissionSub.ApprovalRequests
@@ -598,6 +612,10 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.PamAccounts
);
can([ProjectPermissionMcpEndpointActions.Read], ProjectPermissionSub.McpEndpoints);
can([ProjectPermissionActions.Read], ProjectPermissionSub.McpServers);
can([ProjectPermissionActions.Read], ProjectPermissionSub.McpActivityLogs);
can([ProjectPermissionApprovalRequestActions.Create], ProjectPermissionSub.ApprovalRequests);
return rules;
@@ -667,6 +685,10 @@ const buildViewerPermissionRules = () => {
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
can([ProjectPermissionMcpEndpointActions.Read], ProjectPermissionSub.McpEndpoints);
can([ProjectPermissionActions.Read], ProjectPermissionSub.McpServers);
can([ProjectPermissionActions.Read], ProjectPermissionSub.McpActivityLogs);
return rules;
};

View File

@@ -224,6 +224,14 @@ export enum ProjectPermissionPamSessionActions {
// Terminate = "terminate"
}
export enum ProjectPermissionMcpEndpointActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
Connect = "connect"
}
export enum ProjectPermissionApprovalRequestActions {
Read = "read",
Create = "create"
@@ -286,7 +294,10 @@ export enum ProjectPermissionSub {
PamSessions = "pam-sessions",
CertificateProfiles = "certificate-profiles",
ApprovalRequests = "approval-requests",
ApprovalRequestGrants = "approval-request-grants"
ApprovalRequestGrants = "approval-request-grants",
McpEndpoints = "mcp-endpoints",
McpServers = "mcp-servers",
McpActivityLogs = "mcp-activity-logs"
}
export type SecretSubjectFields = {
@@ -506,6 +517,9 @@ export type ProjectPermissionSet =
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
| [ProjectPermissionMcpEndpointActions, ProjectPermissionSub.McpEndpoints]
| [ProjectPermissionActions, ProjectPermissionSub.McpServers]
| [ProjectPermissionActions, ProjectPermissionSub.McpActivityLogs]
| [
ProjectPermissionCertificateProfileActions,
(
@@ -1120,6 +1134,24 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.McpEndpoints).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionMcpEndpointActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.McpServers).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.McpActivityLogs).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.ApprovalRequests).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalRequestActions).describe(

View File

@@ -79,7 +79,13 @@ export const KeyStorePrefixes = {
GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) =>
`group-member-project-permission:${projectId}:${groupId}:*` as const,
PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const
PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const,
AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const,
// AI MCP Endpoint OAuth
AiMcpEndpointOAuthClient: (clientId: string) => `ai-mcp-endpoint-oauth-client:${clientId}` as const,
AiMcpEndpointOAuthCode: (clientId: string, code: string) => `ai-mcp-endpoint-oauth-code:${clientId}:${code}` as const
};
export const KeyStoreTtls = {

View File

@@ -14,7 +14,7 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
export type TAuthMode =
| {
authMode: AuthMode.JWT;
authMode: AuthMode.JWT | AuthMode.MCP_JWT;
actor: ActorType.USER;
userId: string;
tokenVersionId: string; // the session id of token used
@@ -90,12 +90,21 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
const decodedToken = crypto.jwt().verify(authTokenValue, jwtSecret) as JwtPayload;
switch (decodedToken.authTokenType) {
case AuthTokenType.ACCESS_TOKEN:
case AuthTokenType.ACCESS_TOKEN: {
if (decodedToken?.mcp) {
return {
authMode: AuthMode.MCP_JWT,
token: decodedToken as AuthModeJwtTokenPayload,
actor: ActorType.USER
} as const;
}
return {
authMode: AuthMode.JWT,
token: decodedToken as AuthModeJwtTokenPayload,
actor: ActorType.USER
} as const;
}
case AuthTokenType.API_KEY:
// throw new Error("API Key auth is no longer supported.");
return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const;
@@ -132,6 +141,10 @@ export const injectIdentity = fp(
return;
}
if (req.url === "/api/v1/ai/mcp/servers/oauth/callback") {
return;
}
// Authentication is handled on a route-level
if (req.url === "/api/v1/relays/register-instance-relay") {
return;
@@ -173,6 +186,27 @@ export const injectIdentity = fp(
};
break;
}
case AuthMode.MCP_JWT: {
const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } =
await server.services.authToken.fnValidateJwtIdentity(token);
requestContext.set("orgId", orgId);
requestContext.set("orgName", orgName);
requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" });
req.auth = {
authMode: AuthMode.MCP_JWT,
user,
userId: user.id,
tokenVersionId,
actor,
orgId,
rootOrgId,
parentOrgId,
authMethod: token.authMethod,
isMfaVerified: token.isMfaVerified,
token
};
break;
}
case AuthMode.IDENTITY_ACCESS_TOKEN: {
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp);
const serverCfg = await getServerCfg();

View File

@@ -4,6 +4,10 @@ import { Knex } from "knex";
import { monitorEventLoopDelay } from "perf_hooks";
import { z } from "zod";
import {
registerMcpEndpointAuthServerMetadataRouter,
registerMcpEndpointMetadataRouter
} from "@app/ee/routes/ai/mcp-endpoint-metadata-router";
import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router";
import { registerV1EERoutes } from "@app/ee/routes/v1";
import { registerV2EERoutes } from "@app/ee/routes/v2";
@@ -17,6 +21,16 @@ import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-appr
import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal";
import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal";
import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
import { aiMcpActivityLogDALFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-dal";
import { aiMcpActivityLogServiceFactory } from "@app/ee/services/ai-mcp-activity-log/ai-mcp-activity-log-service";
import { aiMcpEndpointDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-dal";
import { aiMcpEndpointServerDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-server-dal";
import { aiMcpEndpointServerToolDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-server-tool-dal";
import { aiMcpEndpointServiceFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service";
import { aiMcpServerDALFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-dal";
import { aiMcpServerServiceFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-service";
import { aiMcpServerToolDALFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-tool-dal";
import { aiMcpServerUserCredentialDALFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-user-credential-dal";
import { assumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service";
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
@@ -2417,6 +2431,13 @@ export const registerRoutes = async (
const pamResourceDAL = pamResourceDALFactory(db);
const pamAccountDAL = pamAccountDALFactory(db);
const pamSessionDAL = pamSessionDALFactory(db);
const aiMcpServerDAL = aiMcpServerDALFactory(db);
const aiMcpServerToolDAL = aiMcpServerToolDALFactory(db);
const aiMcpServerUserCredentialDAL = aiMcpServerUserCredentialDALFactory(db);
const aiMcpActivityLogDAL = aiMcpActivityLogDALFactory(db);
const aiMcpEndpointDAL = aiMcpEndpointDALFactory(db);
const aiMcpEndpointServerDAL = aiMcpEndpointServerDALFactory(db);
const aiMcpEndpointServerToolDAL = aiMcpEndpointServerToolDALFactory(db);
const pamFolderService = pamFolderServiceFactory({
pamFolderDAL,
@@ -2468,6 +2489,38 @@ export const registerRoutes = async (
kmsService
});
const aiMcpServerService = aiMcpServerServiceFactory({
aiMcpServerDAL,
aiMcpServerToolDAL,
aiMcpServerUserCredentialDAL,
kmsService,
keyStore,
permissionService,
licenseService
});
const aiMcpActivityLogService = aiMcpActivityLogServiceFactory({
aiMcpActivityLogDAL,
permissionService
});
const aiMcpEndpointService = aiMcpEndpointServiceFactory({
aiMcpEndpointDAL,
aiMcpEndpointServerDAL,
aiMcpEndpointServerToolDAL,
aiMcpServerDAL,
aiMcpServerToolDAL,
aiMcpServerUserCredentialDAL,
aiMcpServerService,
kmsService,
keyStore,
authTokenService: tokenService,
aiMcpActivityLogService,
userDAL,
permissionService,
licenseService
});
const migrationService = externalMigrationServiceFactory({
externalMigrationQueue,
userDAL,
@@ -2682,6 +2735,9 @@ export const registerRoutes = async (
identityProject: identityProjectService,
convertor: convertorService,
pkiAlertV2: pkiAlertV2Service,
aiMcpServer: aiMcpServerService,
aiMcpEndpoint: aiMcpEndpointService,
aiMcpActivityLog: aiMcpActivityLogService,
approvalPolicy: approvalPolicyService
});
@@ -2785,6 +2841,10 @@ export const registerRoutes = async (
// register special routes
await server.register(registerCertificateEstRouter, { prefix: "/.well-known/est" });
await server.register(registerMcpEndpointMetadataRouter, { prefix: "/mcp-endpoints" });
await server.register(registerMcpEndpointAuthServerMetadataRouter, {
prefix: "/.well-known/oauth-authorization-server"
});
// register routes for v1
await server.register(

View File

@@ -30,7 +30,8 @@ export enum AuthMode {
SERVICE_TOKEN = "serviceToken",
API_KEY = "apiKey",
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
SCIM_TOKEN = "scimToken"
SCIM_TOKEN = "scimToken",
MCP_JWT = "mcpJwt"
}
export enum ActorType { // would extend to AWS, Azure, ...
@@ -59,6 +60,9 @@ export type AuthModeJwtTokenPayload = {
subOrganizationId?: string;
isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
mcp?: {
endpointId: string;
};
};
export type AuthModeMfaJwtTokenPayload = {

View File

@@ -812,6 +812,31 @@
]
}
]
},
{
"item": "Agentic Manager",
"groups": [
{
"group": "Agentic Manager",
"pages": [
"documentation/platform/agentic-manager/overview",
{
"group": "Concepts",
"pages": [
"documentation/platform/agentic-manager/concepts/mcp-overview"
]
}
]
},
{
"group": "Product Reference",
"pages": [
"documentation/platform/agentic-manager/mcp-servers",
"documentation/platform/agentic-manager/mcp-endpoints",
"documentation/platform/agentic-manager/activity-logs"
]
}
]
}
]
},

View File

@@ -0,0 +1,85 @@
---
title: "Activity Logs"
sidebarTitle: "Activity Logs"
description: "Monitor and audit AI tool usage with detailed activity logs."
---
## Concept
Activity Logs provide complete visibility into how AI agents are using tools through your MCP endpoints. Every tool invocation is logged with detailed information including timestamps, the endpoint used, which tool was called, who initiated the request, and the full request/response payloads.
<CardGroup cols={2}>
<Card title="Security Auditing" icon="shield">
Identify unusual patterns of tool usage, verify authorized access, and detect potential data exfiltration attempts.
</Card>
<Card title="Compliance Reporting" icon="file-certificate">
Meet SOC 2 requirements, support internal security reviews, and enable incident investigation with complete audit trails.
</Card>
<Card title="Debugging & Support" icon="bug">
Examine request payloads, review response errors, and trace the sequence of tool calls when issues arise.
</Card>
<Card title="Usage Analytics" icon="chart-line">
Identify frequently used tools, track usage trends over time, and measure active users per endpoint.
</Card>
</CardGroup>
## What Gets Logged
Every tool invocation through an MCP endpoint creates a log entry containing:
| Field | Description |
|-------|-------------|
| **Timestamp** | When the tool was invoked |
| **Endpoint** | The MCP endpoint used |
| **Tool** | The name of the tool that was called |
| **User** | The user who initiated the request |
| **Request** | The full request payload sent to the tool |
| **Response** | The full response returned by the tool |
## Viewing Activity Logs
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Navigate to Activity Logs">
Head to your Agentic Manager project and select **Activity Logs** from the sidebar.
![activity logs](/images/platform/ai/mcp/mcp-activity-logs.png)
</Step>
<Step title="Filter by time range">
Use the time range selector to filter logs. You can also adjust the timezone using the timezone dropdown.
</Step>
<Step title="Apply filters">
Click **Filter** to apply additional filters:
- **Endpoint**: Filter by specific MCP endpoint
- **Tool**: Filter by specific tool
- **User**: Filter by specific user
- **Server**: Filter by specific MCP server
</Step>
<Step title="View log details">
Click on any log entry to expand it and view the full details:
- **Request**: The JSON payload sent to the tool
- **Response**: The JSON response returned by the tool
![activity log details](/images/platform/ai/mcp/mcp-activity-logs-details.png)
</Step>
</Steps>
</Tab>
</Tabs>
## FAQ
<AccordionGroup>
<Accordion title="Can I export activity logs?">
Yes, activity logs can be exported for external analysis or long-term storage. Enterprise plans include log streaming to external SIEM systems.
</Accordion>
<Accordion title="Are sensitive data in requests/responses masked?">
Infisical supports PII filtering to automatically detect and mask sensitive data in request and response payloads, helping you maintain compliance while preserving audit trail integrity.
</Accordion>
<Accordion title="Do failed tool invocations get logged?">
Yes, all tool invocations are logged regardless of success or failure. Failed invocations include error details in the response payload.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,52 @@
---
title: "Model Context Protocol"
sidebarTitle: "What is MCP?"
description: "Learn what the Model Context Protocol is and how Infisical helps you manage it securely."
---
## What is the Model Context Protocol?
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard that enables AI assistants and agents to securely connect to external data sources and tools. It provides a unified way for AI systems to interact with services like Notion, GitHub, Slack, databases, and more.
Think of MCP as a universal adapter between AI systems and the tools they need. Instead of building custom integrations for each AI platform and each tool, MCP provides a standardized protocol that any AI client can use to connect to any MCP-compatible server.
## How Does MCP Work?
MCP follows a client-server architecture:
- **MCP Clients**: AI applications like Claude, ChatGPT, or custom AI agents that need to access external tools.
- **MCP Servers**: Services that expose tools and capabilities through the MCP protocol. These can be first-party servers (like Notion's official MCP server) or custom servers you build.
When an AI assistant needs to perform an action—like searching Notion, creating a GitHub issue, or querying a database—it communicates with the appropriate MCP server using the standardized protocol.
## Why Do You Need Agentic Manager?
While MCP provides the protocol for AI-tool communication, it doesn't solve the governance and security challenges that organizations face:
### The Challenge
- **Credential Sprawl**: Each user connecting AI tools to MCP servers manages their own credentials, creating security blind spots.
- **No Visibility**: Organizations have no insight into what tools AI agents are using or what data they're accessing.
- **Ungoverned Access**: Without centralized control, AI agents may access tools and data they shouldn't.
- **Compliance Risk**: Audit requirements demand logging and monitoring of AI system activities.
### The Solution
Infisical Agentic Manager acts as a secure gateway between AI clients and MCP servers:
```mermaid
graph LR
A[AI Client] --> B[Infisical MCP Endpoint]
B --> C[Notion]
B --> D[GitHub]
B --> E[Slack]
```
This architecture provides:
1. **Centralized Credential Management**: Store and manage MCP server credentials in one place. Choose between **shared credentials** (one credential for all users) or **personal credentials** (each user authenticates individually) based on your security and compliance needs.
2. **Tool Governance**: Control exactly which tools from each MCP server are available through your endpoints.
3. **Complete Audit Trail**: Every tool invocation is logged with full request/response details, user attribution, and timestamps.
4. **Access Control**: Leverage Infisical's existing access control mechanisms to manage who can use which endpoints.
Learn more about [credential modes](/documentation/platform/agentic-manager/mcp-servers#credential-modes) when configuring MCP servers.

View File

@@ -0,0 +1,129 @@
---
title: "MCP Endpoints"
sidebarTitle: "MCP Endpoints"
description: "Learn how to create and manage MCP endpoints for AI clients."
---
## Concept
MCP Endpoints are the entry points that AI clients (like Claude, ChatGPT, or custom agents) use to access your configured MCP Servers. Instead of connecting AI clients directly to individual MCP servers, you connect them to an Infisical MCP Endpoint which acts as a secure gateway.
This architecture provides several benefits:
<CardGroup cols={2}>
<Card title="Federation" icon="object-group">
Combine tools from multiple MCP servers behind a single endpoint.
</Card>
<Card title="Tool Selection" icon="list-check">
Control exactly which tools are available through each endpoint.
</Card>
<Card title="Access Control" icon="lock">
Manage who can use each endpoint.
</Card>
<Card title="Centralized Logging" icon="scroll">
All tool invocations are logged regardless of which MCP server they target.
</Card>
</CardGroup>
## How It Works
```mermaid
graph LR
A[AI Client<br/>Claude, ChatGPT, etc.] --> B[MCP Endpoint<br/>Infisical]
B --> C[MCP Server 1<br/>Notion]
B --> D[MCP Server 2<br/>GitHub]
```
When you create an MCP endpoint, Infisical generates a unique URL that you can add to your AI client's MCP configuration. The AI client connects to this URL and can access all enabled tools from the connected MCP servers.
## Guide to Creating an MCP Endpoint
In the following steps, we explore how to create an MCP endpoint and connect it to an AI client.
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Navigate to MCP Endpoints">
Head to your Agentic Manager project and select **MCP Endpoints** from the sidebar, then click **Create Endpoint**.
![mcp endpoints list](/images/platform/ai/mcp/mcp-endpoints-list.png)
</Step>
<Step title="Configure endpoint details">
Enter the following details for your endpoint:
- **Name**: A friendly name to identify this endpoint (e.g., "Engineering Team Endpoint")
- **Description (Optional)**: A description of the endpoint's purpose
- **Connected Servers**: A selection of the MCP servers to make available through this endpoint
![mcp endpoint create](/images/platform/ai/mcp/mcp-endpoints-create.png)
</Step>
<Step title="Configure tool selection">
After creating the endpoint, you'll be taken to the endpoint details page. Here you can configure which tools from each connected server are available through this endpoint.
For each connected MCP server, you'll see a list of available tools. Toggle tools on or off to control what AI clients can access.
![mcp endpoint tools](/images/platform/ai/mcp/mcp-endpoints-tools.png)
<Note>
By default, no tools are enabled. You must explicitly enable the tools you want to make available.
</Note>
</Step>
<Step title="Copy the endpoint URL">
The endpoint details page displays the **Endpoint URL**. Copy this URL—you'll need it to configure your AI client.
![mcp endpoint url](/images/platform/ai/mcp/mcp-endpoints-url.png)
</Step>
</Steps>
</Tab>
</Tabs>
## Connecting AI Clients
Once you have your endpoint URL, you can connect AI clients to it.
<Tabs>
<Tab title="Claude">
Add the endpoint to your Claude MCP configuration:
1. Open Claude settings
2. Navigate to the MCP section
3. Add a new server with your Infisical endpoint URL
4. Click **Connect**
When connecting for the first time, Claude will open an authorization page where you grant access to the endpoint. You can configure:
- **Access Duration**: How long the AI client can use the endpoint (e.g., 30 days)
After authorization, Claude can use all enabled tools from your endpoint.
</Tab>
<Tab title="Other AI Clients">
Any MCP-compatible AI client can connect to your endpoint using the endpoint URL.
The general process is:
1. Locate the MCP server configuration in your AI client
2. Add your Infisical endpoint URL as a new server
3. Complete the authorization flow when prompted
Refer to your AI client's documentation for specific configuration steps.
</Tab>
</Tabs>
## FAQ
<AccordionGroup>
<Accordion title="Can I connect the same MCP server to multiple endpoints?">
Yes, you can connect an MCP server to as many endpoints as needed. Each endpoint can have different tools enabled, allowing you to create different access profiles.
</Accordion>
<Accordion title="What happens when I disable a tool?">
When you disable a tool, AI clients connected to the endpoint will no longer be able to use it. The tool won't appear in the client's available tools list.
</Accordion>
<Accordion title="How long does endpoint authorization last?">
When an AI client connects to an endpoint, the user chooses an access duration (e.g., 30 days). After this period, the client will need to re-authorize.
</Accordion>
<Accordion title="Can I revoke an AI client's access?">
Yes, you can revoke access by managing authorized sessions in the endpoint settings. This immediately disconnects the AI client.
</Accordion>
<Accordion title="What if an MCP server requires personal credentials?">
If a connected MCP server uses personal credentials, users will be prompted to authenticate with that server when they first connect to the endpoint. This is a one-time process per server.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,118 @@
---
title: "MCP Servers"
sidebarTitle: "MCP Servers"
description: "Learn how to connect and manage MCP servers in Infisical."
---
## Concept
MCP Servers are external services that expose tools and capabilities through the Model Context Protocol. By connecting MCP servers to Infisical, you can centrally manage access to tools like Notion, GitHub, Slack, and more.
When you add an MCP server to Infisical, the platform discovers all available tools from that server and allows you to make them accessible through [MCP Endpoints](/documentation/platform/agentic-manager/mcp-endpoints).
## Supported MCP Servers
Infisical supports connecting to any remote MCP server that implements the Model Context Protocol over HTTP with OAuth authentication. Popular MCP servers include:
- **Notion** - Search, create, and manage Notion pages and databases
- **GitHub** - Manage repositories, issues, pull requests, and more
- **Slack** - Send messages, manage channels, and interact with workspaces
- **Google Drive** - Access and manage files and documents
- **Linear** - Manage issues and projects
<Note>
Infisical connects to MCP servers over HTTP using the standard remote MCP protocol.
</Note>
## Authentication
MCP servers typically require authentication to access their tools. Infisical supports **OAuth** authentication, which handles the authorization flow automatically.
Some MCP servers support **Dynamic Client Registration**, which means Infisical can automatically register as an OAuth client. For servers that don't support this (like GitHub), you'll need to manually create an OAuth application and provide the client credentials.
## Credential Modes
When adding an MCP server, you choose how credentials are managed:
<CardGroup cols={2}>
<Card title="Shared Credentials" icon="users">
You (the administrator) authorize the MCP server once, and all users who access tools through this server use your credentials.
**Best for:** Shared service accounts, servers without per-user permissions, simplified management.
</Card>
<Card title="Personal Credentials" icon="user-lock">
Each user must authenticate with the MCP server individually before using its tools. Their credentials are stored securely by Infisical.
**Best for:** Per-user audit trails, user-specific permissions, compliance requirements.
</Card>
</CardGroup>
## Guide to Adding an MCP Server
In the following steps, we explore how to add an MCP server to your Agentic Manager project.
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Navigate to MCP Servers">
Head to your Agentic Manager project and select **MCP Servers** from the sidebar, then click **Add MCP Server**.
![mcp servers list](/images/platform/ai/mcp/mcp-servers-list.png)
</Step>
<Step title="Configure server details">
Enter the following details for your MCP server:
- **Name**: A friendly name to identify this server (e.g., "Notion", "GitHub")
- **URL**: The MCP server endpoint URL (e.g., `https://mcp.notion.com/mcp`)
- **Credential Mode**: Choose between **Shared Credentials** or **Personal Credentials**
![mcp server add](/images/platform/ai/mcp/mcp-servers-add.png)
</Step>
<Step title="Configure authentication">
For OAuth authentication, you may need to provide credentials depending on the server:
**For servers with Dynamic Client Registration (e.g., Notion):**
- Simply click **Authorize** to complete the OAuth flow
**For servers without Dynamic Client Registration (e.g., GitHub):**
- Create an OAuth application in the service's developer settings
- Enter the **Client ID** and **Client Secret**
- Click **Authorize** to complete the OAuth flow
![mcp server auth](/images/platform/ai/mcp/mcp-servers-auth.png)
</Step>
<Step title="Review available tools">
After authorization, Infisical discovers and displays all tools available from the MCP server.
You can view each tool's name and description. These tools can now be enabled in [MCP Endpoints](/documentation/platform/agentic-manager/mcp-endpoints).
![mcp server tools](/images/platform/ai/mcp/mcp-servers-tools.png)
</Step>
</Steps>
</Tab>
</Tabs>
## FAQ
<AccordionGroup>
<Accordion title="What MCP server URLs should I use?">
Each MCP server provider publishes their endpoint URL. Common examples:
- **Notion**: `https://mcp.notion.com/mcp`
- **GitHub**: `https://api.githubcopilot.com/mcp/`
Check the service's MCP documentation for the correct URL.
</Accordion>
<Accordion title="How do I create OAuth credentials for GitHub?">
1. Go to GitHub Settings → Developer settings → OAuth Apps
2. Click "New OAuth App"
3. Set the Authorization callback URL to your Infisical instance
4. Copy the Client ID and generate a Client Secret
5. Use these credentials when adding the GitHub MCP server
</Accordion>
<Accordion title="Can I change the credential mode after adding a server?">
Yes, you can update the credential mode by editing the MCP server configuration. Note that changing from shared to personal credentials will require users to re-authenticate.
</Accordion>
<Accordion title="What happens if the MCP server goes offline?">
If an MCP server becomes unavailable, tool invocations through endpoints connected to that server will fail. The Activity Logs will capture these failures for troubleshooting.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,26 @@
---
title: "Agentic Manager"
sidebarTitle: "Overview"
description: "Manage MCP endpoints, connect MCP servers, and configure AI tools with secure governance."
---
Infisical Agentic Manager enables organizations to securely connect AI agents and assistants to external tools through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). It provides a centralized control plane for managing which tools AI systems can access, how they authenticate, and complete visibility into every tool invocation.
As AI agents become more capable and autonomous, organizations need robust infrastructure to govern their access to external systems. Agentic Manager solves this by acting as a secure gateway between AI clients (like Claude, ChatGPT, or custom agents) and the MCP servers that provide tools.
## Core Capabilities
<CardGroup cols={2}>
<Card title="MCP Servers" icon="server" href="/documentation/platform/agentic-manager/mcp-servers">
Connect to remote MCP servers like Notion, GitHub, and Slack with flexible credential management.
</Card>
<Card title="MCP Endpoints" icon="link" href="/documentation/platform/agentic-manager/mcp-endpoints">
Create secure connection URLs for AI clients with granular tool selection.
</Card>
<Card title="Activity Logs" icon="list" href="/documentation/platform/agentic-manager/activity-logs">
Monitor and audit every tool invocation with detailed request/response logging.
</Card>
<Card title="Tool Governance" icon="shield-check">
Fine-grained control over which tools are available through each endpoint.
</Card>
</CardGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

View File

@@ -84,6 +84,10 @@ const PROJECT_TYPE_MENU_ITEMS = [
{
label: "PAM",
value: ProjectType.PAM
},
{
label: "Agentic Manager",
value: ProjectType.AI
}
];

View File

@@ -22,6 +22,7 @@ const SCOPE_BADGE: Record<NonNullable<Props["scope"]>, { icon: LucideIcon; class
[ProjectType.KMS]: { className: "text-project", icon: ProjectIcon },
[ProjectType.PAM]: { className: "text-project", icon: ProjectIcon },
[ProjectType.SecretScanning]: { className: "text-project", icon: ProjectIcon },
[ProjectType.AI]: { className: "text-project", icon: ProjectIcon },
namespace: { className: "text-sub-org", icon: SubOrgIcon },
instance: { className: "text-neutral", icon: InstanceIcon }
};

View File

@@ -16,5 +16,6 @@ export {
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub
} from "./types";

View File

@@ -228,6 +228,14 @@ export enum ProjectPermissionPamSessionActions {
// Terminate = "terminate"
}
export enum ProjectPermissionMcpEndpointActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
Connect = "connect"
}
export enum ProjectPermissionApprovalRequestActions {
Read = "read",
Create = "create"
@@ -350,6 +358,9 @@ export enum ProjectPermissionSub {
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions",
McpEndpoints = "mcp-endpoints",
McpServers = "mcp-servers",
McpActivityLogs = "mcp-activity-logs",
ApprovalRequests = "approval-requests",
ApprovalRequestGrants = "approval-request-grants"
}
@@ -591,6 +602,9 @@ export type ProjectPermissionSet =
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
| [ProjectPermissionApprovalRequestActions, ProjectPermissionSub.ApprovalRequests]
| [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants];
| [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants]
| [ProjectPermissionMcpEndpointActions, ProjectPermissionSub.McpEndpoints]
| [ProjectPermissionActions, ProjectPermissionSub.McpServers]
| [ProjectPermissionActions, ProjectPermissionSub.McpActivityLogs];
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;

View File

@@ -27,6 +27,7 @@ export {
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub,
useProjectPermission
} from "./ProjectPermissionContext";

View File

@@ -79,6 +79,8 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
return `/organizations/$orgId/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:
return `/organizations/$orgId/projects/${type}/$projectId/accounts` as const;
case ProjectType.AI:
return `/organizations/$orgId/projects/${type}/$projectId/overview` as const;
default:
return `/organizations/$orgId/projects/${type}/$projectId/overview` as const;
}
@@ -91,7 +93,8 @@ export const getProjectTitle = (type: ProjectType) => {
[ProjectType.CertificateManager]: "Certificate Manager",
[ProjectType.SSH]: "SSH",
[ProjectType.SecretScanning]: "Secret Scanning",
[ProjectType.PAM]: "PAM"
[ProjectType.PAM]: "PAM",
[ProjectType.AI]: "Agentic Manager"
};
return titleConvert[type];
};
@@ -103,7 +106,8 @@ export const getProjectLottieIcon = (type: ProjectType) => {
[ProjectType.CertificateManager]: "note",
[ProjectType.SSH]: "terminal",
[ProjectType.SecretScanning]: "secret-scan",
[ProjectType.PAM]: "groups"
[ProjectType.PAM]: "groups",
[ProjectType.AI]: "moving-block"
};
return titleConvert[type];
};

View File

@@ -0,0 +1,2 @@
export { aiMcpActivityLogKeys, useListAiMcpActivityLogs } from "./queries";
export type { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types";

View File

@@ -0,0 +1,52 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { onRequestError } from "@app/hooks/api/reactQuery";
import { TReactQueryOptions } from "@app/types/reactQuery";
import { TAiMcpActivityLog, TListAiMcpActivityLogsFilter } from "./types";
export const aiMcpActivityLogKeys = {
all: ["aiMcpActivityLogs"] as const,
list: (projectId: string, filters: TListAiMcpActivityLogsFilter) =>
[...aiMcpActivityLogKeys.all, "list", projectId, filters] as const
};
export const useListAiMcpActivityLogs = (
filters: TListAiMcpActivityLogsFilter,
options: TReactQueryOptions["options"] = {}
) => {
return useInfiniteQuery({
initialPageParam: 0,
queryKey: aiMcpActivityLogKeys.list(filters.projectId, filters),
queryFn: async ({ pageParam }) => {
try {
const { data } = await apiRequest.get<{ activityLogs: TAiMcpActivityLog[] }>(
"/api/v1/ai/mcp/activity-logs",
{
params: {
projectId: filters.projectId,
offset: pageParam,
limit: filters.limit,
startDate: filters.startDate.toISOString(),
endDate: filters.endDate.toISOString(),
...(filters.endpointName ? { endpointName: filters.endpointName } : {}),
...(filters.serverName ? { serverName: filters.serverName } : {}),
...(filters.toolName ? { toolName: filters.toolName } : {}),
...(filters.actor ? { actor: filters.actor } : {})
}
}
);
return data.activityLogs;
} catch (error) {
onRequestError(error);
return [];
}
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
placeholderData: (prev) => prev,
enabled: Boolean(filters.projectId),
...options
});
};

View File

@@ -0,0 +1,27 @@
export type TAiMcpActivityLog = {
id: string;
projectId: string;
endpointName: string;
serverName: string;
toolName: string;
actor: string;
request: unknown;
response: unknown;
createdAt: string;
updatedAt: string;
};
export type TListAiMcpActivityLogsFilter = {
projectId: string;
endpointName?: string;
serverName?: string;
toolName?: string;
actor?: string;
startDate: Date;
endDate: Date;
limit: number;
};
export type TListAiMcpActivityLogsDTO = {
projectId: string;
};

View File

@@ -0,0 +1,19 @@
export {
useBulkUpdateEndpointTools,
useCreateAiMcpEndpoint,
useDeleteAiMcpEndpoint,
useDisableEndpointTool,
useEnableEndpointTool,
useFinalizeMcpEndpointOAuth,
useInitiateServerOAuth,
useSaveUserServerCredential,
useUpdateAiMcpEndpoint
} from "./mutations";
export {
aiMcpEndpointKeys,
useGetAiMcpEndpointById,
useGetServersRequiringAuth,
useListAiMcpEndpoints,
useListEndpointTools
} from "./queries";
export * from "./types";

View File

@@ -0,0 +1,171 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { aiMcpEndpointKeys } from "./queries";
import {
TAiMcpEndpoint,
TAiMcpEndpointToolConfig,
TBulkUpdateEndpointToolsDTO,
TCreateAiMcpEndpointDTO,
TDeleteAiMcpEndpointDTO,
TDisableEndpointToolDTO,
TEnableEndpointToolDTO,
TFinalizeMcpEndpointOAuthDTO,
TInitiateServerOAuthDTO,
TSaveUserServerCredentialDTO,
TUpdateAiMcpEndpointDTO
} from "./types";
export const useCreateAiMcpEndpoint = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TCreateAiMcpEndpointDTO) => {
const { data } = await apiRequest.post<{ endpoint: TAiMcpEndpoint }>(
"/api/v1/ai/mcp/endpoints",
dto
);
return data.endpoint;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.list(variables.projectId)
});
}
});
};
export const useUpdateAiMcpEndpoint = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId, ...dto }: TUpdateAiMcpEndpointDTO) => {
const { data } = await apiRequest.patch<{ endpoint: TAiMcpEndpoint }>(
`/api/v1/ai/mcp/endpoints/${endpointId}`,
dto
);
return data.endpoint;
},
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.list(data.projectId)
});
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.byId(data.id)
});
}
});
};
export const useDeleteAiMcpEndpoint = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId }: TDeleteAiMcpEndpointDTO) => {
const { data } = await apiRequest.delete<{ endpoint: TAiMcpEndpoint }>(
`/api/v1/ai/mcp/endpoints/${endpointId}`
);
return data.endpoint;
},
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.list(data.projectId)
});
}
});
};
export const useEnableEndpointTool = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId, serverToolId }: TEnableEndpointToolDTO) => {
const { data } = await apiRequest.post<{ tool: TAiMcpEndpointToolConfig }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/tools/${serverToolId}`
);
return data.tool;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.tools(variables.endpointId)
});
}
});
};
export const useDisableEndpointTool = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId, serverToolId }: TDisableEndpointToolDTO) => {
await apiRequest.delete(`/api/v1/ai/mcp/endpoints/${endpointId}/tools/${serverToolId}`);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.tools(variables.endpointId)
});
}
});
};
export const useBulkUpdateEndpointTools = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId, tools }: TBulkUpdateEndpointToolsDTO) => {
const { data } = await apiRequest.patch<{ tools: TAiMcpEndpointToolConfig[] }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/tools/bulk`,
{ tools }
);
return data.tools;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.tools(variables.endpointId)
});
}
});
};
export const useFinalizeMcpEndpointOAuth = () => {
return useMutation({
mutationFn: async ({ endpointId, ...body }: TFinalizeMcpEndpointOAuthDTO) => {
const { data } = await apiRequest.post<{ callbackUrl: string }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/oauth/finalize`,
body
);
return data;
}
});
};
export const useInitiateServerOAuth = () => {
return useMutation({
mutationFn: async ({ endpointId, serverId }: TInitiateServerOAuthDTO) => {
const { data } = await apiRequest.post<{ authUrl: string; sessionId: string }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/servers/${serverId}/oauth/initiate`
);
return data;
}
});
};
export const useSaveUserServerCredential = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ endpointId, serverId, ...body }: TSaveUserServerCredentialDTO) => {
const { data } = await apiRequest.post<{ success: boolean }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/servers/${serverId}/credentials`,
body
);
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: aiMcpEndpointKeys.serversRequiringAuth(variables.endpointId)
});
}
});
};

View File

@@ -0,0 +1,75 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TAiMcpEndpoint,
TAiMcpEndpointToolConfig,
TAiMcpEndpointWithServerIds,
TListAiMcpEndpointsDTO,
TServerAuthStatus
} from "./types";
export const aiMcpEndpointKeys = {
all: ["aiMcpEndpoints"] as const,
list: (projectId: string) => [...aiMcpEndpointKeys.all, "list", projectId] as const,
byId: (endpointId: string) => [...aiMcpEndpointKeys.all, "byId", endpointId] as const,
tools: (endpointId: string) => [...aiMcpEndpointKeys.all, "tools", endpointId] as const,
serversRequiringAuth: (endpointId: string) =>
[...aiMcpEndpointKeys.all, "serversRequiringAuth", endpointId] as const
};
export const useListAiMcpEndpoints = ({ projectId }: TListAiMcpEndpointsDTO) => {
return useQuery({
queryKey: aiMcpEndpointKeys.list(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{
endpoints: TAiMcpEndpoint[];
totalCount: number;
}>("/api/v1/ai/mcp/endpoints", {
params: { projectId }
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetAiMcpEndpointById = ({ endpointId }: { endpointId: string }) => {
return useQuery({
queryKey: aiMcpEndpointKeys.byId(endpointId),
queryFn: async () => {
const { data } = await apiRequest.get<{ endpoint: TAiMcpEndpointWithServerIds }>(
`/api/v1/ai/mcp/endpoints/${endpointId}`
);
return data.endpoint;
},
enabled: Boolean(endpointId)
});
};
export const useListEndpointTools = ({ endpointId }: { endpointId: string }) => {
return useQuery({
queryKey: aiMcpEndpointKeys.tools(endpointId),
queryFn: async () => {
const { data } = await apiRequest.get<{ tools: TAiMcpEndpointToolConfig[] }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/tools`
);
return data.tools;
},
enabled: Boolean(endpointId)
});
};
export const useGetServersRequiringAuth = ({ endpointId }: { endpointId: string }) => {
return useQuery({
queryKey: aiMcpEndpointKeys.serversRequiringAuth(endpointId),
queryFn: async () => {
const { data } = await apiRequest.get<{ servers: TServerAuthStatus[] }>(
`/api/v1/ai/mcp/endpoints/${endpointId}/servers-requiring-auth`
);
return data.servers;
},
enabled: Boolean(endpointId)
});
};

View File

@@ -0,0 +1,105 @@
export type TAiMcpEndpoint = {
id: string;
name: string;
description: string | null;
status: string | null;
piiFiltering?: boolean;
projectId: string;
createdAt: string;
updatedAt: string;
connectedServers: number;
activeTools: number;
};
export type TAiMcpEndpointWithServerIds = TAiMcpEndpoint & {
serverIds: string[];
};
export type TCreateAiMcpEndpointDTO = {
projectId: string;
name: string;
description?: string;
serverIds?: string[];
};
export type TUpdateAiMcpEndpointDTO = {
endpointId: string;
name?: string;
description?: string;
serverIds?: string[];
piiFiltering?: boolean;
};
export type TDeleteAiMcpEndpointDTO = {
endpointId: string;
};
export type TListAiMcpEndpointsDTO = {
projectId: string;
};
export type TGetAiMcpEndpointDTO = {
endpointId: string;
};
export type TAiMcpEndpointToolConfig = {
id: string;
aiMcpEndpointId: string;
aiMcpServerToolId: string;
isEnabled: boolean;
};
export type TListEndpointToolsDTO = {
endpointId: string;
};
export type TEnableEndpointToolDTO = {
endpointId: string;
serverToolId: string;
};
export type TDisableEndpointToolDTO = {
endpointId: string;
serverToolId: string;
};
export type TBulkUpdateEndpointToolsDTO = {
endpointId: string;
tools: Array<{
serverToolId: string;
isEnabled: boolean;
}>;
};
export type TFinalizeMcpEndpointOAuthDTO = {
endpointId: string;
response_type: string;
client_id: string;
code_challenge: string;
code_challenge_method: string;
redirect_uri: string;
resource: string;
expireIn: string;
};
// Personal credentials types
export type TServerAuthStatus = {
id: string;
name: string;
url: string;
hasCredentials: boolean;
};
export type TInitiateServerOAuthDTO = {
endpointId: string;
serverId: string;
};
export type TSaveUserServerCredentialDTO = {
endpointId: string;
serverId: string;
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
};

View File

@@ -0,0 +1,15 @@
export {
useCreateAiMcpServer,
useDeleteAiMcpServer,
useInitiateOAuth,
useSyncAiMcpServerTools,
useUpdateAiMcpServer
} from "./mutations";
export {
aiMcpServerKeys,
useGetAiMcpServerById,
useGetOAuthStatus,
useListAiMcpServers,
useListAiMcpServerTools
} from "./queries";
export * from "./types";

View File

@@ -0,0 +1,105 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { aiMcpServerKeys } from "./queries";
import {
TAiMcpServer,
TAiMcpServerTool,
TCreateAiMcpServerDTO,
TDeleteAiMcpServerDTO,
TInitiateOAuthDTO,
TInitiateOAuthResponse,
TSyncAiMcpServerToolsDTO,
TUpdateAiMcpServerDTO
} from "./types";
export const useCreateAiMcpServer = () => {
const queryClient = useQueryClient();
return useMutation<TAiMcpServer, object, TCreateAiMcpServerDTO>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<{
server: TAiMcpServer;
}>("/api/v1/ai/mcp/servers", data);
return response.server;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: aiMcpServerKeys.list({ projectId })
});
}
});
};
export const useUpdateAiMcpServer = () => {
const queryClient = useQueryClient();
return useMutation<TAiMcpServer, object, TUpdateAiMcpServerDTO>({
mutationFn: async ({ serverId, ...data }) => {
const { data: response } = await apiRequest.patch<{
server: TAiMcpServer;
}>(`/api/v1/ai/mcp/servers/${serverId}`, data);
return response.server;
},
onSuccess: (server, { serverId }) => {
queryClient.invalidateQueries({
queryKey: aiMcpServerKeys.list({ projectId: server.projectId })
});
queryClient.invalidateQueries({
queryKey: aiMcpServerKeys.getById(serverId)
});
}
});
};
export const useDeleteAiMcpServer = () => {
const queryClient = useQueryClient();
return useMutation<TAiMcpServer, object, TDeleteAiMcpServerDTO>({
mutationFn: async ({ serverId }) => {
const { data: response } = await apiRequest.delete<{
server: TAiMcpServer;
}>(`/api/v1/ai/mcp/servers/${serverId}`);
return response.server;
},
onSuccess: (server, { serverId }) => {
queryClient.invalidateQueries({
queryKey: aiMcpServerKeys.list({ projectId: server.projectId })
});
queryClient.removeQueries({
queryKey: aiMcpServerKeys.getById(serverId)
});
}
});
};
export const useInitiateOAuth = () => {
return useMutation<TInitiateOAuthResponse, object, TInitiateOAuthDTO>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<TInitiateOAuthResponse>(
"/api/v1/ai/mcp/servers/oauth/initiate",
data
);
return response;
}
});
};
export const useSyncAiMcpServerTools = () => {
const queryClient = useQueryClient();
return useMutation<{ tools: TAiMcpServerTool[] }, object, TSyncAiMcpServerToolsDTO>({
mutationFn: async ({ serverId }) => {
const { data: response } = await apiRequest.post<{
tools: TAiMcpServerTool[];
}>(`/api/v1/ai/mcp/servers/${serverId}/tools/sync`);
return response;
},
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({
queryKey: aiMcpServerKeys.tools(serverId)
});
}
});
};

View File

@@ -0,0 +1,91 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TAiMcpServer,
TAiMcpServerTool,
TGetOAuthStatusDTO,
TListAiMcpServersDTO,
TListAiMcpServerToolsDTO,
TOAuthStatusResponse
} from "./types";
export const aiMcpServerKeys = {
all: ["ai-mcp-servers"] as const,
list: (params: { projectId: string }) => [...aiMcpServerKeys.all, "list", params] as const,
getById: (serverId: string) => [...aiMcpServerKeys.all, "get-by-id", serverId] as const,
oauthStatus: (sessionId: string) => [...aiMcpServerKeys.all, "oauth-status", sessionId] as const,
tools: (serverId: string) => [...aiMcpServerKeys.all, "tools", serverId] as const
};
export const useListAiMcpServers = ({
projectId,
limit = 100,
offset = 0
}: TListAiMcpServersDTO) => {
return useQuery({
queryKey: aiMcpServerKeys.list({ projectId }),
queryFn: async () => {
const { data } = await apiRequest.get<{
servers: TAiMcpServer[];
totalCount: number;
}>("/api/v1/ai/mcp/servers", {
params: { projectId, limit, offset }
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetAiMcpServerById = (
serverId: string,
options?: Omit<UseQueryOptions<TAiMcpServer>, "queryKey" | "queryFn">
) => {
return useQuery({
queryKey: aiMcpServerKeys.getById(serverId),
queryFn: async () => {
const { data } = await apiRequest.get<{
server: TAiMcpServer;
}>(`/api/v1/ai/mcp/servers/${serverId}`);
return data.server;
},
enabled: Boolean(serverId),
...options
});
};
export const useListAiMcpServerTools = ({ serverId }: TListAiMcpServerToolsDTO) => {
return useQuery({
queryKey: aiMcpServerKeys.tools(serverId),
queryFn: async () => {
const { data } = await apiRequest.get<{
tools: TAiMcpServerTool[];
}>(`/api/v1/ai/mcp/servers/${serverId}/tools`);
return data;
},
enabled: Boolean(serverId)
});
};
export const useGetOAuthStatus = (
{ sessionId }: TGetOAuthStatusDTO,
{ enabled = true, refetchInterval }: { enabled?: boolean; refetchInterval?: number | false }
) => {
return useQuery({
queryKey: aiMcpServerKeys.oauthStatus(sessionId),
queryFn: async () => {
const { data } = await apiRequest.get<TOAuthStatusResponse>(
`/api/v1/ai/mcp/servers/oauth/status/${sessionId}`
);
return data;
},
enabled: Boolean(sessionId) && enabled,
refetchInterval,
// Keep polling even when window loses focus (user is in OAuth popup)
refetchIntervalInBackground: true,
// Don't use React Query's retry mechanism - we want continuous polling, not 3 retries then stop
retry: false
});
};

View File

@@ -0,0 +1,136 @@
export enum AiMcpServerCredentialMode {
SHARED = "shared",
PERSONAL = "personal"
}
export enum AiMcpServerAuthMethod {
BASIC = "basic",
BEARER = "bearer",
OAUTH = "oauth"
}
export enum AiMcpServerStatus {
ACTIVE = "active",
INACTIVE = "inactive",
UNINITIALIZED = "uninitialized"
}
// Basic auth credentials
export type TBasicAuthCredentials = {
authMethod: AiMcpServerAuthMethod.BASIC;
credentials: {
username: string;
password: string;
};
};
// Bearer token credentials
export type TBearerAuthCredentials = {
authMethod: AiMcpServerAuthMethod.BEARER;
credentials: {
token: string;
};
};
// OAuth credentials (obtained after OAuth flow)
export type TOAuthCredentials = {
authMethod: AiMcpServerAuthMethod.OAUTH;
credentials: {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
};
};
export type TAiMcpServerCredentials =
| TBasicAuthCredentials
| TBearerAuthCredentials
| TOAuthCredentials;
export type TAiMcpServer = {
id: string;
name: string;
url: string;
description?: string;
status: AiMcpServerStatus;
credentialMode: AiMcpServerCredentialMode;
authMethod: AiMcpServerAuthMethod;
projectId: string;
toolsCount?: number;
createdAt: string;
updatedAt: string;
};
// Create MCP Server DTOs
export type TCreateAiMcpServerDTO = {
projectId: string;
name: string;
url: string;
description?: string;
credentialMode: AiMcpServerCredentialMode;
oauthClientId?: string;
oauthClientSecret?: string;
} & TAiMcpServerCredentials;
// OAuth initiate DTO
export type TInitiateOAuthDTO = {
projectId: string;
url: string;
clientId?: string;
clientSecret?: string;
};
export type TInitiateOAuthResponse = {
authUrl: string;
sessionId: string;
};
// OAuth status DTO
export type TGetOAuthStatusDTO = {
sessionId: string;
};
export type TOAuthStatusResponse = {
authorized: boolean;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
tokenType?: string;
clientId?: string;
clientSecret?: string;
};
// List MCP Servers DTO
export type TListAiMcpServersDTO = {
projectId: string;
limit?: number;
offset?: number;
};
// Delete MCP Server DTO
export type TDeleteAiMcpServerDTO = {
serverId: string;
};
export type TUpdateAiMcpServerDTO = {
serverId: string;
name?: string;
description?: string;
};
export type TAiMcpServerTool = {
id: string;
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
aiMcpServerId: string;
};
export type TListAiMcpServerToolsDTO = {
serverId: string;
};
export type TSyncAiMcpServerToolsDTO = {
serverId: string;
};

View File

@@ -308,6 +308,32 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.APPROVAL_REQUEST_GRANT_GET]: "Get Approval Request Grant",
[EventType.APPROVAL_REQUEST_GRANT_REVOKE]: "Revoke Approval Request Grant",
// MCP Endpoints
[EventType.MCP_ENDPOINT_CREATE]: "Create MCP Endpoint",
[EventType.MCP_ENDPOINT_UPDATE]: "Update MCP Endpoint",
[EventType.MCP_ENDPOINT_DELETE]: "Delete MCP Endpoint",
[EventType.MCP_ENDPOINT_GET]: "Get MCP Endpoint",
[EventType.MCP_ENDPOINT_LIST]: "List MCP Endpoints",
[EventType.MCP_ENDPOINT_LIST_TOOLS]: "List MCP Endpoint Tools",
[EventType.MCP_ENDPOINT_ENABLE_TOOL]: "Enable MCP Endpoint Tool",
[EventType.MCP_ENDPOINT_DISABLE_TOOL]: "Disable MCP Endpoint Tool",
[EventType.MCP_ENDPOINT_BULK_UPDATE_TOOLS]: "Bulk Update MCP Endpoint Tools",
[EventType.MCP_ENDPOINT_OAUTH_CLIENT_REGISTER]: "Register MCP OAuth Client",
[EventType.MCP_ENDPOINT_OAUTH_AUTHORIZE]: "Authorize MCP OAuth Client",
[EventType.MCP_ENDPOINT_CONNECT]: "Connect to MCP Endpoint",
[EventType.MCP_ENDPOINT_SAVE_USER_CREDENTIAL]: "Save MCP Server User Credential",
// MCP Servers
[EventType.MCP_SERVER_CREATE]: "Create MCP Server",
[EventType.MCP_SERVER_UPDATE]: "Update MCP Server",
[EventType.MCP_SERVER_DELETE]: "Delete MCP Server",
[EventType.MCP_SERVER_GET]: "Get MCP Server",
[EventType.MCP_SERVER_LIST]: "List MCP Servers",
[EventType.MCP_SERVER_LIST_TOOLS]: "List MCP Server Tools",
[EventType.MCP_SERVER_SYNC_TOOLS]: "Sync MCP Server Tools",
// MCP Activity Logs
[EventType.MCP_ACTIVITY_LOG_LIST]: "List MCP Activity Logs",
[EventType.CREATE_DYNAMIC_SECRET]: "Create Dynamic Secret",
[EventType.UPDATE_DYNAMIC_SECRET]: "Update Dynamic Secret",
[EventType.DELETE_DYNAMIC_SECRET]: "Delete Dynamic Secret",
@@ -375,5 +401,32 @@ export const projectToEventsMap: Partial<Record<ProjectType, EventType[]>> = {
EventType.PAM_RESOURCE_CREATE,
EventType.PAM_RESOURCE_UPDATE,
EventType.PAM_RESOURCE_DELETE
],
[ProjectType.AI]: [
...sharedProjectEvents,
// MCP Endpoints
EventType.MCP_ENDPOINT_CREATE,
EventType.MCP_ENDPOINT_UPDATE,
EventType.MCP_ENDPOINT_DELETE,
EventType.MCP_ENDPOINT_GET,
EventType.MCP_ENDPOINT_LIST,
EventType.MCP_ENDPOINT_LIST_TOOLS,
EventType.MCP_ENDPOINT_ENABLE_TOOL,
EventType.MCP_ENDPOINT_DISABLE_TOOL,
EventType.MCP_ENDPOINT_BULK_UPDATE_TOOLS,
EventType.MCP_ENDPOINT_OAUTH_CLIENT_REGISTER,
EventType.MCP_ENDPOINT_OAUTH_AUTHORIZE,
EventType.MCP_ENDPOINT_CONNECT,
EventType.MCP_ENDPOINT_SAVE_USER_CREDENTIAL,
// MCP Servers
EventType.MCP_SERVER_CREATE,
EventType.MCP_SERVER_UPDATE,
EventType.MCP_SERVER_DELETE,
EventType.MCP_SERVER_GET,
EventType.MCP_SERVER_LIST,
EventType.MCP_SERVER_LIST_TOOLS,
EventType.MCP_SERVER_SYNC_TOOLS,
// MCP Activity Logs
EventType.MCP_ACTIVITY_LOG_LIST
]
};

View File

@@ -301,6 +301,33 @@ export enum EventType {
APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get",
APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke",
// MCP Endpoints
MCP_ENDPOINT_CREATE = "mcp-endpoint-create",
MCP_ENDPOINT_UPDATE = "mcp-endpoint-update",
MCP_ENDPOINT_DELETE = "mcp-endpoint-delete",
MCP_ENDPOINT_GET = "mcp-endpoint-get",
MCP_ENDPOINT_LIST = "mcp-endpoint-list",
MCP_ENDPOINT_LIST_TOOLS = "mcp-endpoint-list-tools",
MCP_ENDPOINT_ENABLE_TOOL = "mcp-endpoint-enable-tool",
MCP_ENDPOINT_DISABLE_TOOL = "mcp-endpoint-disable-tool",
MCP_ENDPOINT_BULK_UPDATE_TOOLS = "mcp-endpoint-bulk-update-tools",
MCP_ENDPOINT_OAUTH_CLIENT_REGISTER = "mcp-endpoint-oauth-client-register",
MCP_ENDPOINT_OAUTH_AUTHORIZE = "mcp-endpoint-oauth-authorize",
MCP_ENDPOINT_CONNECT = "mcp-endpoint-connect",
MCP_ENDPOINT_SAVE_USER_CREDENTIAL = "mcp-endpoint-save-user-credential",
// MCP Servers
MCP_SERVER_CREATE = "mcp-server-create",
MCP_SERVER_UPDATE = "mcp-server-update",
MCP_SERVER_DELETE = "mcp-server-delete",
MCP_SERVER_GET = "mcp-server-get",
MCP_SERVER_LIST = "mcp-server-list",
MCP_SERVER_LIST_TOOLS = "mcp-server-list-tools",
MCP_SERVER_SYNC_TOOLS = "mcp-server-sync-tools",
// MCP Activity Logs
MCP_ACTIVITY_LOG_LIST = "mcp-activity-log-list",
// Dynamic Secrets
CREATE_DYNAMIC_SECRET = "create-dynamic-secret",
UPDATE_DYNAMIC_SECRET = "update-dynamic-secret",

View File

@@ -1,5 +1,8 @@
export * from "./accessApproval";
export * from "./admin";
export * from "./aiMcpActivityLogs";
export * from "./aiMcpEndpoints";
export * from "./aiMcpServers";
export * from "./apiKeys";
export * from "./approvalGrants";
export * from "./approvalPolicies";

View File

@@ -14,7 +14,8 @@ export enum ProjectType {
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning",
PAM = "pam"
PAM = "pam",
AI = "ai"
}
export enum ProjectUserMembershipTemporaryMode {

View File

@@ -63,4 +63,5 @@ export type SubscriptionPlan = {
cardDeclinedDays?: number;
machineIdentityAuthTemplates: boolean;
pam: boolean;
ai: boolean;
};

View File

@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Tab, TabList, Tabs } from "@app/components/v2";
import { useOrganization, useProject, useProjectPermission, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
export const AILayout = () => {
const { currentOrg } = useOrganization();
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { assumedPrivilegeDetails } = useProjectPermission();
const location = useLocation();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]);
useEffect(() => {
if (subscription && !subscription.ai) {
handlePopUpOpen("upgradePlan", {
description:
"Your current plan does not provide access to Infisical AI. To unlock this feature, please upgrade to Infisical Enterprise plan.",
isEnterpriseFeature: true
});
}
}, [subscription]);
return (
<>
<div className="dark flex h-full w-full flex-col overflow-x-hidden bg-mineshaft-900">
<div className="border-y border-t-project/10 border-b-project/5 bg-gradient-to-b from-project/[0.075] to-project/[0.025] px-4 pt-0.5">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<nav className="w-full">
<Tabs value="selected">
<TabList className="border-b-0">
<Link
to="/organizations/$orgId/projects/ai/$projectId/overview"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>MCP</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/ai/$projectId/access-management"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive ||
location.pathname.match(/\/groups\/|\/identities\/|\/members\/|\/roles\//)
? "selected"
: ""
}
>
Access Control
</Tab>
)}
</Link>
<Link
to="/organizations/$orgId/projects/ai/$projectId/audit-logs"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Audit Logs</Tab>}
</Link>
<Link
to="/organizations/$orgId/projects/ai/$projectId/settings"
params={{
orgId: currentOrg.id,
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Settings</Tab>}
</Link>
</TabList>
</Tabs>
</nav>
</motion.div>
</div>
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 px-12 pt-10 pb-4">
<Outlet />
</div>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text={popUp.upgradePlan.data?.description}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/>
</>
);
};

View File

@@ -0,0 +1 @@
export { AILayout } from "./AILayout";

View File

@@ -43,7 +43,8 @@ const PROJECT_TYPE_NAME: Record<ProjectType, string> = {
[ProjectType.SSH]: "SSH",
[ProjectType.KMS]: "KMS",
[ProjectType.PAM]: "PAM",
[ProjectType.SecretScanning]: "Secret Scanning"
[ProjectType.SecretScanning]: "Secret Scanning",
[ProjectType.AI]: "Agentic Manager"
};
const ProjectSelectInner = () => {

View File

@@ -0,0 +1,211 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import {
faBan,
faChevronLeft,
faEllipsisV,
faNetworkWired
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
ContentLoader,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState
} from "@app/components/v2";
import { ProjectPermissionMcpEndpointActions, ProjectPermissionSub } from "@app/context";
import { useDeleteAiMcpEndpoint, useGetAiMcpEndpointById } from "@app/hooks/api";
import { EditMCPEndpointModal } from "../MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal";
import {
MCPEndpointConnectedServersSection,
MCPEndpointConnectionSection,
MCPEndpointDetailsSection,
MCPEndpointToolSelectionSection
} from "./components";
const PageContent = () => {
const navigate = useNavigate();
const params = useParams({
strict: false
}) as { endpointId?: string; projectId?: string; orgId?: string };
const { endpointId, projectId, orgId } = params;
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { data: mcpEndpoint, isPending } = useGetAiMcpEndpointById({
endpointId: endpointId!
});
const deleteEndpoint = useDeleteAiMcpEndpoint();
if (isPending) {
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader />
</div>
);
}
if (!mcpEndpoint) {
return (
<div className="flex h-full w-full items-center justify-center px-20">
<EmptyState
className="max-w-2xl rounded-md text-center"
icon={faBan}
title={`Could not find MCP Endpoint with ID ${endpointId}`}
/>
</div>
);
}
const handleBack = () => {
navigate({
to: "/organizations/$orgId/projects/ai/$projectId/overview",
params: { orgId: orgId!, projectId: projectId! }
});
};
const handleDeleteConfirm = async () => {
if (!mcpEndpoint) return;
try {
await deleteEndpoint.mutateAsync({ endpointId: mcpEndpoint.id });
createNotification({
text: `MCP endpoint "${mcpEndpoint.name}" deleted successfully`,
type: "success"
});
handleBack();
} catch (error) {
console.error("Failed to delete MCP endpoint:", error);
createNotification({
text: "Failed to delete MCP endpoint",
type: "error"
});
}
};
return (
<div className="container mx-auto flex max-w-7xl flex-col px-6 py-6 text-mineshaft-50">
<button
type="button"
onClick={handleBack}
className="mb-4 flex w-fit items-center gap-1 text-sm text-bunker-300 hover:text-primary-400"
>
<FontAwesomeIcon icon={faChevronLeft} className="text-xs" />
MCP Endpoints
</button>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-mineshaft-700">
<FontAwesomeIcon icon={faNetworkWired} className="text-xl text-primary" />
</div>
<div>
<h1 className="text-2xl font-semibold text-mineshaft-100">{mcpEndpoint.name}</h1>
<p className="text-sm text-bunker-300">MCP Endpoint</p>
</div>
</div>
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" colorSchema="secondary">
<FontAwesomeIcon icon={faEllipsisV} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<ProjectPermissionCan
I={ProjectPermissionMcpEndpointActions.Edit}
a={ProjectPermissionSub.McpEndpoints}
>
{(isAllowed) => (
<DropdownMenuItem
onClick={() => setIsEditModalOpen(true)}
isDisabled={!isAllowed}
>
Edit Endpoint
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMcpEndpointActions.Delete}
a={ProjectPermissionSub.McpEndpoints}
>
{(isAllowed) => (
<DropdownMenuItem
onClick={() => setIsDeleteModalOpen(true)}
className="text-red-500"
isDisabled={!isAllowed}
>
Delete Endpoint
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex gap-6">
{/* Left Column - Details, Connection, Connected Servers */}
<div className="flex w-96 flex-col gap-4">
<MCPEndpointDetailsSection
endpoint={mcpEndpoint}
onEdit={() => setIsEditModalOpen(true)}
/>
<MCPEndpointConnectionSection endpoint={mcpEndpoint} />
<MCPEndpointConnectedServersSection
endpointId={mcpEndpoint.id}
projectId={mcpEndpoint.projectId}
serverIds={mcpEndpoint.serverIds}
/>
</div>
{/* Right Column - Usage Statistics & Tool Selection */}
<div className="flex flex-1 flex-col gap-4">
<MCPEndpointToolSelectionSection
endpointId={mcpEndpoint.id}
projectId={mcpEndpoint.projectId}
serverIds={mcpEndpoint.serverIds}
/>
</div>
</div>
<EditMCPEndpointModal
isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
endpoint={mcpEndpoint}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete MCP Endpoint ${mcpEndpoint.name}?`}
onChange={(isOpen) => setIsDeleteModalOpen(isOpen)}
deleteKey={mcpEndpoint.name}
onDeleteApproved={handleDeleteConfirm}
/>
</div>
);
};
export const MCPEndpointDetailPage = () => {
return (
<>
<Helmet>
<title>MCP Endpoint | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<PageContent />
</>
);
};

View File

@@ -0,0 +1,202 @@
import { useState } from "react";
import { faCheck, faPencil, faServer, faTimes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Checkbox, IconButton, Spinner } from "@app/components/v2";
import { ProjectPermissionMcpEndpointActions, ProjectPermissionSub } from "@app/context";
import { useListAiMcpServers, useUpdateAiMcpEndpoint } from "@app/hooks/api";
type Props = {
endpointId: string;
projectId: string;
serverIds: string[];
};
export const MCPEndpointConnectedServersSection = ({ endpointId, projectId, serverIds }: Props) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedServerIds, setSelectedServerIds] = useState<string[]>(serverIds);
const { data: serversData, isLoading: isLoadingServers } = useListAiMcpServers({ projectId });
const updateEndpoint = useUpdateAiMcpEndpoint();
const servers = serversData?.servers || [];
const connectedServers = servers.filter((server) => serverIds.includes(server.id));
const handleServerToggle = (serverId: string) => {
setSelectedServerIds((current) =>
current.includes(serverId) ? current.filter((id) => id !== serverId) : [...current, serverId]
);
};
const handleStartEdit = () => {
setSelectedServerIds(serverIds);
setIsEditing(true);
};
const handleCancel = () => {
setSelectedServerIds(serverIds);
setIsEditing(false);
};
const handleSave = async () => {
try {
await updateEndpoint.mutateAsync({
endpointId,
serverIds: selectedServerIds
});
createNotification({
text: "Connected servers updated successfully",
type: "success"
});
setIsEditing(false);
} catch (error) {
console.error("Failed to update connected servers:", error);
createNotification({
text: "Failed to update connected servers",
type: "error"
});
}
};
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">MCP Servers</h3>
{isEditing ? (
<div className="flex items-center gap-1">
<IconButton
ariaLabel="Cancel"
variant="plain"
size="sm"
onClick={handleCancel}
isDisabled={updateEndpoint.isPending}
>
<FontAwesomeIcon
icon={faTimes}
className="text-bunker-300 hover:text-mineshaft-100"
/>
</IconButton>
<IconButton
ariaLabel="Save"
variant="plain"
size="sm"
onClick={handleSave}
isDisabled={updateEndpoint.isPending}
>
{updateEndpoint.isPending ? (
<Spinner size="xs" />
) : (
<FontAwesomeIcon
icon={faCheck}
className="text-primary-500 hover:text-primary-400"
/>
)}
</IconButton>
</div>
) : (
<ProjectPermissionCan
I={ProjectPermissionMcpEndpointActions.Edit}
a={ProjectPermissionSub.McpEndpoints}
>
{(isAllowed) => (
<IconButton
ariaLabel="Edit connected servers"
variant="plain"
size="sm"
onClick={handleStartEdit}
isDisabled={!isAllowed}
>
<FontAwesomeIcon
icon={faPencil}
className="text-bunker-300 hover:text-mineshaft-100"
/>
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="space-y-2">
{isEditing && (
<>
{isLoadingServers && <p className="text-sm text-bunker-400">Loading servers...</p>}
{!isLoadingServers && servers.length === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<FontAwesomeIcon icon={faServer} className="mb-2 text-2xl text-bunker-400" />
<p className="text-sm text-bunker-400">No MCP servers available</p>
<p className="text-xs text-bunker-500">
Add MCP servers first to connect them to this endpoint
</p>
</div>
)}
{!isLoadingServers &&
servers.map((server) => (
<div
key={server.id}
className="flex items-start gap-3 rounded-md p-2 transition-colors hover:bg-mineshaft-700"
>
<Checkbox
id={`server-${server.id}`}
className="mt-0.5"
isChecked={selectedServerIds.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
isDisabled={updateEndpoint.isPending}
/>
<label
htmlFor={`server-${server.id}`}
className="flex flex-1 cursor-pointer items-start gap-2"
>
<FontAwesomeIcon icon={faServer} className="mt-0.5 text-sm text-bunker-400" />
<div className="flex-1">
<p className="text-sm text-mineshaft-200">{server.name}</p>
{server.description && (
<p className="text-xs text-bunker-400">{server.description}</p>
)}
</div>
</label>
<div
className={`mt-1 h-2 w-2 rounded-full ${
server.status === "active" ? "bg-emerald-500" : "bg-red-500"
}`}
/>
</div>
))}
{selectedServerIds.length > 0 && (
<p className="mt-1 text-xs text-bunker-400">
{selectedServerIds.length} server{selectedServerIds.length !== 1 ? "s" : ""}{" "}
selected
</p>
)}
</>
)}
{!isEditing && connectedServers.length === 0 && (
<p className="py-2 text-sm text-bunker-400">No servers connected</p>
)}
{!isEditing &&
connectedServers.length > 0 &&
connectedServers.map((server) => (
<div
key={server.id}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-mineshaft-700"
>
<FontAwesomeIcon icon={faServer} className="text-sm text-bunker-400" />
<div className="flex-1">
<p className="text-sm text-mineshaft-200">{server.name}</p>
{server.description && (
<p className="text-xs text-bunker-400">{server.description}</p>
)}
</div>
<div
className={`h-2 w-2 rounded-full ${
server.status === "active" ? "bg-emerald-500" : "bg-red-500"
}`}
/>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,282 @@
import { useEffect, useState } from "react";
import { faCheck, faCopy, faKey, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
GenericFieldLabel,
IconButton,
Input,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TAiMcpEndpointWithServerIds } from "@app/hooks/api";
type Props = {
endpoint: TAiMcpEndpointWithServerIds;
};
type AuthToken = {
id: string;
name: string;
token: string;
createdAt: string;
};
const STORAGE_KEY_PREFIX = "mcp_endpoint_tokens_";
const SHOW_AUTH_TOKENS_SECTION = false;
export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {
const [isCopied, setIsCopied] = useToggle(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useToggle(false);
const [tokenName, setTokenName] = useState("");
const [authTokens, setAuthTokens] = useState<AuthToken[]>([]);
const [copiedTokenId, setCopiedTokenId] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const endpointUrl = `${window.location.origin}/api/v1/ai/mcp/endpoints/${endpoint.id}/connect`;
const storageKey = `${STORAGE_KEY_PREFIX}${endpoint.id}`;
// Load tokens from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
setAuthTokens(JSON.parse(stored));
} catch (e) {
console.error("Failed to parse stored tokens", e);
}
}
setHasLoaded(true);
}, [storageKey]);
// Save tokens to localStorage whenever they change (after initial load)
useEffect(() => {
if (hasLoaded) {
localStorage.setItem(storageKey, JSON.stringify(authTokens));
}
}, [authTokens, storageKey, hasLoaded]);
const handleCopy = () => {
navigator.clipboard.writeText(endpointUrl);
setIsCopied.on();
createNotification({
text: "Endpoint URL copied to clipboard",
type: "info"
});
setTimeout(() => setIsCopied.off(), 2000);
};
const generateToken = () => {
const prefix = "mcp_";
const randomPart =
Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
return `${prefix}${randomPart}`;
};
const handleCreateToken = () => {
if (!tokenName.trim()) {
createNotification({
text: "Please enter a token name",
type: "error"
});
return;
}
const newToken: AuthToken = {
id: Math.random().toString(36).substring(2, 15),
name: tokenName.trim(),
token: generateToken(),
createdAt: new Date().toISOString()
};
setAuthTokens([newToken, ...authTokens]);
setTokenName("");
setIsCreateModalOpen.off();
createNotification({
text: "Authentication token created successfully",
type: "success"
});
};
const handleDeleteToken = (tokenId: string) => {
setAuthTokens(authTokens.filter((t) => t.id !== tokenId));
createNotification({
text: "Authentication token deleted",
type: "info"
});
};
const handleCopyToken = (token: string, tokenId: string) => {
navigator.clipboard.writeText(token);
setCopiedTokenId(tokenId);
createNotification({
text: "Token copied to clipboard",
type: "info"
});
setTimeout(() => setCopiedTokenId(null), 2000);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
});
};
const truncateToken = (token: string) => {
if (token.length <= 12) return token;
return `${token.substring(0, 12)}...`;
};
return (
<>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Connection</h3>
</div>
<div className="space-y-3">
<GenericFieldLabel label="Endpoint">
<div className="flex items-center gap-2">
<div className="flex-1 overflow-hidden rounded border border-mineshaft-500 bg-mineshaft-700">
<code className="block overflow-x-auto px-3 py-2 font-mono text-sm whitespace-nowrap text-mineshaft-200">
{endpointUrl}
</code>
</div>
<Tooltip content={isCopied ? "Copied!" : "Copy URL"}>
<IconButton
ariaLabel="Copy endpoint URL"
variant="outline_bg"
size="sm"
onClick={handleCopy}
>
<FontAwesomeIcon icon={isCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</GenericFieldLabel>
{SHOW_AUTH_TOKENS_SECTION && (
<div className="space-y-3 border-t border-mineshaft-500 pt-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-mineshaft-300">
<FontAwesomeIcon icon={faKey} className="text-xs" />
<span>Authentication Tokens</span>
</div>
<Button
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={setIsCreateModalOpen.on}
>
Create
</Button>
</div>
{authTokens.length > 0 && (
<div className="space-y-2">
{authTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between rounded border border-mineshaft-600 bg-mineshaft-800 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<h4 className="truncate text-sm font-medium text-mineshaft-100">
{token.name}
</h4>
<Tooltip content="Delete token">
<IconButton
ariaLabel="Delete token"
variant="plain"
size="xs"
onClick={() => handleDeleteToken(token.id)}
className="text-red-500 hover:text-red-400"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
<p className="mt-0.5 text-xs text-mineshaft-400">
Created {formatDate(token.createdAt)}
</p>
<div className="mt-1.5 flex items-center gap-2">
<code className="font-mono text-xs text-mineshaft-300">
{truncateToken(token.token)}
</code>
<Tooltip content={copiedTokenId === token.id ? "Copied!" : "Copy token"}>
<button
type="button"
onClick={() => handleCopyToken(token.token, token.id)}
className="text-mineshaft-400 transition-colors hover:text-mineshaft-200"
>
<FontAwesomeIcon
icon={copiedTokenId === token.id ? faCheck : faCopy}
className="text-xs"
/>
</button>
</Tooltip>
</div>
</div>
</div>
))}
</div>
)}
{authTokens.length === 0 && (
<p className="py-2 text-xs text-mineshaft-400 italic">
No authentication tokens created yet
</p>
)}
</div>
)}
</div>
</div>
<Modal
isOpen={isCreateModalOpen}
onOpenChange={(open) => !open && setIsCreateModalOpen.off()}
>
<ModalContent
title="Create Authentication Token"
subTitle="Create a new token for MCP clients without OAuth support"
>
<form
onSubmit={(e) => {
e.preventDefault();
handleCreateToken();
}}
>
<FormControl label="Token Name" isRequired>
<Input
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="e.g., Production AI Agent"
autoFocus
/>
</FormControl>
<div className="mt-6 flex items-center gap-2">
<Button type="submit" colorSchema="primary" isDisabled={!tokenName.trim()}>
Create Token
</Button>
<Button variant="outline_bg" onClick={setIsCreateModalOpen.off}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
</>
);
};

View File

@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { faEdit, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
GenericFieldLabel,
IconButton,
Input,
Select,
SelectItem,
Switch,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionMcpEndpointActions, ProjectPermissionSub } from "@app/context";
import { TAiMcpEndpointWithServerIds, useUpdateAiMcpEndpoint } from "@app/hooks/api";
type Props = {
endpoint: TAiMcpEndpointWithServerIds;
onEdit: VoidFunction;
};
type RateLimitSettings = {
enabled: boolean;
limit: number;
timeUnit: "minute" | "hour" | "day";
};
const RATE_LIMIT_STORAGE_KEY_PREFIX = "mcp_endpoint_rate_limit_";
const getStatusLabel = (status: string | null) => {
const labels: Record<string, string> = {
active: "Active",
inactive: "Inactive"
};
return labels[status || "inactive"] || "Unknown";
};
const getStatusColor = (status: string | null) => {
const colors: Record<string, string> = {
active: "bg-emerald-500",
inactive: "bg-red-500"
};
return colors[status || "inactive"] || "bg-red-500";
};
const SHOW_PII_FILTERING_AND_RATE_LIMITING_SECTION = false;
export const MCPEndpointDetailsSection = ({ endpoint, onEdit }: Props) => {
const updateEndpoint = useUpdateAiMcpEndpoint();
const [rateLimitSettings, setRateLimitSettings] = useState<RateLimitSettings>({
enabled: false,
limit: 100,
timeUnit: "hour"
});
const [hasLoadedRateLimit, setHasLoadedRateLimit] = useState(false);
const rateLimitStorageKey = `${RATE_LIMIT_STORAGE_KEY_PREFIX}${endpoint.id}`;
// Load rate limit settings from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(rateLimitStorageKey);
if (stored) {
try {
setRateLimitSettings(JSON.parse(stored));
} catch (e) {
console.error("Failed to parse stored rate limit settings", e);
}
}
setHasLoadedRateLimit(true);
}, [rateLimitStorageKey]);
// Save rate limit settings to localStorage whenever they change
useEffect(() => {
if (hasLoadedRateLimit) {
localStorage.setItem(rateLimitStorageKey, JSON.stringify(rateLimitSettings));
}
}, [rateLimitSettings, rateLimitStorageKey, hasLoadedRateLimit]);
const handlePiiFilteringToggle = async (checked: boolean) => {
try {
await updateEndpoint.mutateAsync({
endpointId: endpoint.id,
piiFiltering: checked
});
createNotification({
text: `PII filtering ${checked ? "enabled" : "disabled"} successfully`,
type: "success"
});
} catch (error) {
console.error("Failed to update PII filtering:", error);
createNotification({
text: "Failed to update PII filtering setting",
type: "error"
});
}
};
const handleRateLimitToggle = (checked: boolean) => {
setRateLimitSettings((prev) => ({ ...prev, enabled: checked }));
createNotification({
text: `Rate limiting ${checked ? "enabled" : "disabled"}`,
type: "success"
});
};
const handleRateLimitChange = (limit: string) => {
const numLimit = parseInt(limit, 10);
if (!Number.isNaN(numLimit) && numLimit > 0) {
setRateLimitSettings((prev) => ({ ...prev, limit: numLimit }));
}
};
const handleTimeUnitChange = (timeUnit: string) => {
setRateLimitSettings((prev) => ({
...prev,
timeUnit: timeUnit as "minute" | "hour" | "day"
}));
};
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<ProjectPermissionCan
I={ProjectPermissionMcpEndpointActions.Edit}
a={ProjectPermissionSub.McpEndpoints}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="Edit endpoint details"
onClick={onEdit}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="space-y-3">
<GenericFieldLabel label="Name">{endpoint.name}</GenericFieldLabel>
<GenericFieldLabel label="Description">
{endpoint.description || <span className="text-bunker-400">No description</span>}
</GenericFieldLabel>
<GenericFieldLabel label="Status">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${getStatusColor(endpoint.status)}`} />
{getStatusLabel(endpoint.status)}
</div>
</GenericFieldLabel>
<GenericFieldLabel label="Created">
{format(new Date(endpoint.createdAt), "yyyy-MM-dd, hh:mm aaa")}
</GenericFieldLabel>
{SHOW_PII_FILTERING_AND_RATE_LIMITING_SECTION && (
<>
<div className="border-t border-mineshaft-500 pt-3">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-mineshaft-200">PII Filtering</span>
<Tooltip
content="When enabled, personally identifiable information (credit cards, addresses, phone numbers, etc.) will be redacted in requests and responses"
className="max-w-xs"
>
<FontAwesomeIcon
icon={faInfoCircle}
className="cursor-default text-bunker-400 hover:text-bunker-300"
/>
</Tooltip>
</div>
<span className="text-xs text-bunker-400">Redact sensitive data</span>
</div>
<Switch
id={`pii-filtering-${endpoint.id}`}
isChecked={endpoint.piiFiltering ?? false}
onCheckedChange={handlePiiFilteringToggle}
isDisabled={updateEndpoint.isPending}
/>
</div>
</div>
<div className="pt-1">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-mineshaft-200">Rate Limiting</span>
</div>
<span className="text-xs text-bunker-400">Limit tool invocations per user</span>
</div>
<Switch
id={`rate-limiting-${endpoint.id}`}
isChecked={rateLimitSettings.enabled}
onCheckedChange={handleRateLimitToggle}
/>
</div>
{rateLimitSettings.enabled && (
<div className="mt-3 grid grid-cols-2 gap-3">
<Input
type="number"
value={rateLimitSettings.limit.toString()}
onChange={(e) => handleRateLimitChange(e.target.value)}
min={1}
placeholder="100"
/>
<Select value={rateLimitSettings.timeUnit} onValueChange={handleTimeUnitChange}>
<SelectItem value="minute">per minute</SelectItem>
<SelectItem value="hour">per hour</SelectItem>
<SelectItem value="day">per day</SelectItem>
</Select>
</div>
)}
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,235 @@
import { useMemo, useState } from "react";
import {
faChevronDown,
faChevronUp,
faInfoCircle,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Input, Switch, Tooltip } from "@app/components/v2";
import {
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import {
TAiMcpEndpointToolConfig,
useDisableEndpointTool,
useEnableEndpointTool,
useListAiMcpServers,
useListAiMcpServerTools,
useListEndpointTools
} from "@app/hooks/api";
type Props = {
endpointId: string;
projectId: string;
serverIds: string[];
};
type ServerToolsSectionProps = {
serverId: string;
serverName: string;
serverStatus: string;
searchQuery: string;
toolConfigs: TAiMcpEndpointToolConfig[];
onToolToggle: (serverToolId: string, isEnabled: boolean) => void;
isUpdating: boolean;
canEdit: boolean;
};
const ServerToolsSection = ({
serverId,
serverName,
serverStatus,
searchQuery,
toolConfigs,
onToolToggle,
isUpdating,
canEdit
}: ServerToolsSectionProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { data: toolsData } = useListAiMcpServerTools({ serverId });
const tools = toolsData?.tools || [];
// Create a set of enabled tool IDs for quick lookup
// Presence in the list = enabled, absence = disabled
const enabledToolIds = useMemo(() => {
return new Set(toolConfigs.map((config) => config.aiMcpServerToolId));
}, [toolConfigs]);
// Filter tools based on search query
const filteredTools = tools.filter(
(tool) =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
// Count enabled tools
const enabledCount = tools.filter((tool) => enabledToolIds.has(tool.id)).length;
const totalCount = tools.length;
// Check if tool is enabled
const isToolEnabled = (toolId: string) => {
return enabledToolIds.has(toolId);
};
if (filteredTools.length === 0 && searchQuery) {
return null; // Hide section if no tools match search
}
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-mineshaft-700"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faServer} className="text-sm text-bunker-400" />
<span className="text-sm text-mineshaft-200">{serverName}</span>
<div
className={`h-2 w-2 rounded-full ${
serverStatus === "active" ? "bg-emerald-500" : "bg-red-500"
}`}
/>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
{enabledCount}/{totalCount} Enabled
</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-xs text-bunker-400"
/>
</div>
</button>
{isExpanded && filteredTools.length > 0 && (
<div className="border-t border-mineshaft-600">
<div className="grid grid-cols-[1fr_auto] gap-2 border-b border-mineshaft-600 px-4 py-2 text-xs font-medium tracking-wider text-bunker-300 uppercase">
<span>Tool Name</span>
<span>Enabled</span>
</div>
<div className="divide-y divide-mineshaft-600">
{filteredTools.map((tool) => (
<div key={tool.id} className="grid grid-cols-[1fr_auto] items-center gap-2 px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-mineshaft-200">{tool.name}</span>
{tool.description && (
<Tooltip content={tool.description}>
<FontAwesomeIcon
icon={faInfoCircle}
className="text-xs text-bunker-400 hover:text-bunker-300"
/>
</Tooltip>
)}
</div>
<Switch
id={`tool-${tool.id}`}
isChecked={isToolEnabled(tool.id)}
onCheckedChange={(checked) => onToolToggle(tool.id, checked)}
isDisabled={isUpdating || !canEdit}
/>
</div>
))}
</div>
</div>
)}
{isExpanded && tools.length === 0 && (
<div className="border-t border-mineshaft-600 px-4 py-4 text-center text-sm text-bunker-400">
No tools available from this server
</div>
)}
</div>
);
};
export const MCPEndpointToolSelectionSection = ({ endpointId, projectId, serverIds }: Props) => {
const [searchQuery, setSearchQuery] = useState("");
const { permission } = useProjectPermission();
const canEdit = permission.can(
ProjectPermissionMcpEndpointActions.Edit,
ProjectPermissionSub.McpEndpoints
);
const { data: serversData } = useListAiMcpServers({ projectId });
const { data: toolConfigs = [] } = useListEndpointTools({ endpointId });
const enableTool = useEnableEndpointTool();
const disableTool = useDisableEndpointTool();
const connectedServers =
serversData?.servers.filter((server) => serverIds.includes(server.id)) || [];
const handleToolToggle = async (serverToolId: string, isEnabled: boolean) => {
try {
if (isEnabled) {
await enableTool.mutateAsync({ endpointId, serverToolId });
} else {
await disableTool.mutateAsync({ endpointId, serverToolId });
}
} catch (error) {
console.error("Failed to update tool:", error);
createNotification({
text: "Failed to update tool configuration",
type: "error"
});
}
};
return (
<div className="flex w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-4">
<div>
<h3 className="text-lg font-medium text-mineshaft-100">Tool Selection</h3>
<p className="mt-1 text-sm text-bunker-300">
Control which tools from connected MCP servers are available through this endpoint
</p>
</div>
<div className="relative">
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="absolute top-1/2 left-3 z-10 -translate-y-1/2 text-bunker-400"
/>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tools..."
className="pl-10"
/>
</div>
<div className="space-y-3">
{connectedServers.length === 0 ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 text-center">
<FontAwesomeIcon icon={faServer} className="mb-2 text-2xl text-bunker-400" />
<p className="text-sm text-bunker-400">No MCP servers connected to this endpoint</p>
<p className="mt-1 text-xs text-bunker-400">
Connect servers to configure available tools
</p>
</div>
) : (
connectedServers.map((server) => (
<ServerToolsSection
key={server.id}
serverId={server.id}
serverName={server.name}
serverStatus={server.status}
searchQuery={searchQuery}
toolConfigs={toolConfigs}
onToolToggle={handleToolToggle}
isUpdating={enableTool.isPending || disableTool.isPending}
canEdit={canEdit}
/>
))
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,164 @@
import { useMemo } from "react";
import { faChartLine, faTools, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TAiMcpActivityLog, useListEndpointTools } from "@app/hooks/api";
type Props = {
endpointId: string;
endpointName: string;
};
type StatCardProps = {
icon: typeof faChartLine;
label: string;
value: string | number;
subtitle?: string;
trend?: {
value: number;
label: string;
};
};
const StatCard = ({ icon, label, value, subtitle, trend }: StatCardProps) => {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-bunker-300">
<FontAwesomeIcon icon={icon} className="text-sm" />
<span className="text-xs tracking-wide uppercase">{label}</span>
</div>
<div className="flex flex-col">
<div className="text-2xl font-semibold text-mineshaft-100">{value}</div>
{subtitle && <div className="text-xs text-bunker-300">{subtitle}</div>}
{trend && (
<div className={`text-xs ${trend.value >= 0 ? "text-emerald-500" : "text-red-500"}`}>
{trend.value >= 0 ? "+" : ""}
{trend.value} {trend.label}
</div>
)}
</div>
</div>
);
};
export const MCPEndpointUsageStatisticsSection = ({ endpointId, endpointName }: Props) => {
const activityLogs: TAiMcpActivityLog[] = [];
const { data: endpointTools = [] } = useListEndpointTools({ endpointId });
const statistics = useMemo(() => {
// Filter logs for this specific endpoint
const endpointLogs = activityLogs.filter((log) => log.endpointName === endpointName);
// Total requests
const totalRequests = endpointLogs.length;
// Get unique tools used (from activity logs)
const uniqueTools = new Set(endpointLogs.map((log) => log.toolName));
const activeToolsCount = uniqueTools.size;
// Total enabled tools for the endpoint
const totalEnabledTools = endpointTools.length;
// Get unique actors (users)
const uniqueActors = new Set(endpointLogs.map((log) => log.actor));
const uniqueUsersCount = uniqueActors.size;
// Calculate weekly trends (last 7 days)
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const lastWeekLogs = endpointLogs.filter((log) => new Date(log.createdAt) >= sevenDaysAgo);
const previousWeekLogs = endpointLogs.filter(
(log) => new Date(log.createdAt) >= fourteenDaysAgo && new Date(log.createdAt) < sevenDaysAgo
);
// Calculate request trend
const lastWeekRequests = lastWeekLogs.length;
const previousWeekRequests = previousWeekLogs.length;
let requestTrend: number | null = null;
let requestTrendLabel = "";
if (previousWeekRequests > 0) {
// Show percentage change when there's a baseline
requestTrend = Math.round(
((lastWeekRequests - previousWeekRequests) / previousWeekRequests) * 100
);
requestTrendLabel = "% from last week";
} else if (lastWeekRequests > 0) {
// Show absolute count when starting from zero
requestTrend = lastWeekRequests;
requestTrendLabel = "this week";
}
// Calculate new users this week
const lastWeekActors = new Set(lastWeekLogs.map((log) => log.actor));
const previousActors = new Set(
endpointLogs.filter((log) => new Date(log.createdAt) < sevenDaysAgo).map((log) => log.actor)
);
const newUsersThisWeek = Array.from(lastWeekActors).filter(
(actor) => !previousActors.has(actor)
).length;
// Calculate active tools
const lastWeekTools = new Set(lastWeekLogs.map((log) => log.toolName));
const previousWeekTools = new Set(previousWeekLogs.map((log) => log.toolName));
const activeToolsThisWeek = lastWeekTools.size;
const activeToolsPreviousWeek = previousWeekTools.size;
return {
totalRequests,
requestTrend,
requestTrendLabel,
activeToolsCount,
activeToolsThisWeek,
activeToolsPreviousWeek,
uniqueUsersCount,
newUsersThisWeek,
totalEnabledTools
};
}, [activityLogs, endpointName, endpointTools]);
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Usage Statistics</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<StatCard
icon={faChartLine}
label="Total Requests"
value={statistics.totalRequests.toLocaleString()}
trend={
statistics.requestTrend !== null
? {
value: statistics.requestTrend,
label: statistics.requestTrendLabel
}
: undefined
}
/>
<StatCard
icon={faTools}
label="Active Tools"
value={statistics.activeToolsCount}
subtitle={`of ${statistics.totalEnabledTools} total`}
/>
<StatCard
icon={faUsers}
label="Unique Users"
value={statistics.uniqueUsersCount}
trend={
statistics.newUsersThisWeek > 0
? {
value: statistics.newUsersThisWeek,
label: "new this week"
}
: undefined
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export { MCPEndpointConnectedServersSection } from "./MCPEndpointConnectedServersSection";
export { MCPEndpointConnectionSection } from "./MCPEndpointConnectionSection";
export { MCPEndpointDetailsSection } from "./MCPEndpointDetailsSection";
export { MCPEndpointToolSelectionSection } from "./MCPEndpointToolSelectionSection";
export { MCPEndpointUsageStatisticsSection } from "./MCPEndpointUsageStatisticsSection";

View File

@@ -0,0 +1 @@
export { MCPEndpointDetailPage } from "./MCPEndpointDetailPage";

View File

@@ -0,0 +1,26 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { MCPEndpointDetailPage } from "./MCPEndpointDetailPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId"
)({
component: MCPEndpointDetailPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "MCP Endpoints",
link: linkOptions({
to: "/organizations/$orgId/projects/ai/$projectId/overview",
params: { orgId: params.orgId, projectId: params.projectId }
})
},
{
label: "Endpoint Details"
}
]
};
}
});

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useProject } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { MCPActivityLogsTab } from "./components/MCPActivityLogsTab";
import { MCPEndpointsTab } from "./components/MCPEndpointsTab";
import { MCPServersTab } from "./components/MCPServersTab";
enum TabSections {
MCPEndpoints = "mcp-endpoints",
MCPServers = "mcp-servers",
ActivityLogs = "activity-logs"
}
export const MCPPage = () => {
const { t } = useTranslation();
const { currentProject } = useProject();
const [activeTab, setActiveTab] = useState(TabSections.MCPEndpoints);
if (!currentProject) {
return <ContentLoader />;
}
return (
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "MCP Management" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.AI}
title="MCP Management"
description="Manage MCP endpoints, connect MCP servers, and configure tools with secure governance"
/>
<Tabs
orientation="vertical"
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.MCPEndpoints}>
MCP Endpoints
</Tab>
<Tab variant="project" value={TabSections.MCPServers}>
MCP Servers
</Tab>
<Tab variant="project" value={TabSections.ActivityLogs}>
Activity Logs
</Tab>
</TabList>
<TabPanel value={TabSections.MCPEndpoints}>
<MCPEndpointsTab />
</TabPanel>
<TabPanel value={TabSections.MCPServers}>
<MCPServersTab />
</TabPanel>
<TabPanel value={TabSections.ActivityLogs}>
<MCPActivityLogsTab />
</TabPanel>
</Tabs>
</div>
</div>
);
};

View File

@@ -0,0 +1,345 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCalendar, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import {
Button,
DatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
FormControl,
Input,
Select,
SelectItem
} from "@app/components/v2";
import { formatDateTime, Timezone } from "@app/helpers/datetime";
import {
mcpActivityLogDateFilterFormSchema,
MCPActivityLogDateFilterType,
TMCPActivityLogDateFilterFormData
} from "./types";
type Props = {
setFilter: (data: TMCPActivityLogDateFilterFormData) => void;
filter: TMCPActivityLogDateFilterFormData;
setTimezone: (timezone: Timezone) => void;
timezone: Timezone;
};
const RELATIVE_VALUES = ["5m", "30m", "1h", "3h", "12h"];
const RELATIVE_OPTIONS = [
{ label: "Minutes", unit: "m", values: [5, 10, 15, 30, 45] },
{ label: "Hours", unit: "h", values: [1, 2, 3, 6, 8, 12] },
{ label: "Days", unit: "d", values: [1, 2, 3, 4, 5, 6] },
{ label: "Weeks", unit: "w", values: [1, 2, 3, 4] }
];
export const MCPActivityLogsDateFilter = ({ setFilter, filter, timezone, setTimezone }: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const [isPopupOpen, setIsPopOpen] = useState(false);
const { control, watch, handleSubmit, formState } = useForm<TMCPActivityLogDateFilterFormData>({
resolver: zodResolver(mcpActivityLogDateFilterFormSchema),
values: filter
});
const selectType = watch("type");
const isCustomRelative =
filter.type === MCPActivityLogDateFilterType.Relative &&
!RELATIVE_VALUES.includes(filter.relativeModeValue || "");
const onSubmit = (data: TMCPActivityLogDateFilterFormData) => {
const endDate = data.type === MCPActivityLogDateFilterType.Relative ? new Date() : data.endDate;
const startDate =
data.type === MCPActivityLogDateFilterType.Relative && data.relativeModeValue
? new Date(Number(new Date()) - ms(data.relativeModeValue))
: data.startDate;
setFilter({
...data,
startDate,
endDate
});
setIsPopOpen(false);
};
return (
<>
<DropdownMenu open={isPopupOpen} onOpenChange={(el) => setIsPopOpen(el)}>
<div className="flex items-center">
{filter.type === MCPActivityLogDateFilterType.Relative ? (
<>
{RELATIVE_VALUES.map((el) => (
<Button
variant="outline_bg"
className={twMerge(
"w-[3.82rem] rounded-none px-3 py-2 font-normal first:rounded-l-md",
filter.type === MCPActivityLogDateFilterType.Relative &&
filter.relativeModeValue === el &&
"border-primary/40 bg-primary/10"
)}
key={`${el}-relative`}
onClick={() =>
setFilter({
relativeModeValue: el,
type: MCPActivityLogDateFilterType.Relative,
endDate: new Date(),
startDate: new Date(Number(new Date()) - ms(el))
})
}
>
{el}
</Button>
))}
</>
) : (
<div className="flex w-[19.1rem] items-center justify-between rounded-l-md border border-transparent bg-mineshaft-600 px-5 py-2 text-sm text-bunker-200">
<div>
{formatDateTime({
timezone,
timestamp: filter.startDate,
dateFormat: "yyyy/MM/dd HH:mm"
})}
</div>
<div>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faChevronRight} />
</div>
<div>
{formatDateTime({
timezone,
timestamp: filter.endDate,
dateFormat: "yyyy/MM/dd HH:mm"
})}
</div>
</div>
)}
<DropdownMenuTrigger asChild>
<Button
variant="outline_bg"
className={twMerge(
"w-32 rounded-none rounded-r-md px-3 py-2 font-normal",
(filter.type === MCPActivityLogDateFilterType.Absolute || isCustomRelative) &&
"border-primary/40 bg-primary/10"
)}
>
<span>Custom</span> <FontAwesomeIcon className="ml-1" icon={faCalendar} />
{filter.type === MCPActivityLogDateFilterType.Relative && isCustomRelative && (
<span className="ml-1">({filter.relativeModeValue})</span>
)}
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
className="min-w-[434px]! bg-mineshaft-800 p-4"
align="end"
sideOffset={8}
>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="type"
render={({ field }) => (
<div className="mb-7">
<Button
onClick={() => field.onChange(MCPActivityLogDateFilterType.Absolute)}
variant="outline_bg"
className={twMerge(
"h-8 rounded-r-none font-normal",
field.value === MCPActivityLogDateFilterType.Absolute &&
"border-primary/40 bg-primary/10"
)}
>
Absolute
</Button>
<Button
onClick={() => field.onChange(MCPActivityLogDateFilterType.Relative)}
variant="outline_bg"
className={twMerge(
"h-8 rounded-l-none font-normal",
field.value === MCPActivityLogDateFilterType.Relative &&
"border-primary/40 bg-primary/10"
)}
>
Relative
</Button>
</div>
)}
/>
{selectType === MCPActivityLogDateFilterType.Relative && (
<Controller
control={control}
name="relativeModeValue"
render={({ field, fieldState: { error } }) => {
const duration = field.value?.substring(0, field.value.length - 1);
const unitOfTime = field.value?.at(-1);
return (
<div className="flex flex-col gap-4">
{RELATIVE_OPTIONS.map(({ label, unit, values }) => (
<div key={unit} className="flex items-center gap-2">
<div className="w-16">{label}</div>
{values.map((v) => {
const value = `${v}${unit}`;
return (
<Button
key={value}
variant="outline_bg"
onClick={() => field.onChange(value)}
className={twMerge(
"h-8 w-12",
field.value === value && "border-primary/40 bg-primary/10"
)}
>
{v}
</Button>
);
})}
</div>
))}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<FormControl
className="mb-0 w-28"
label="Duration"
isError={Boolean(error)}
>
<Input
type="number"
value={duration}
onChange={(val) => {
const durationVal = val.target.value
? Number(val.target.value)
: undefined;
field.onChange(`${durationVal}${unitOfTime}`);
}}
max={60}
min={1}
/>
</FormControl>
<FormControl className="mb-0 w-36" label="Unit of Time">
<Select
value={unitOfTime}
onValueChange={(val) => field.onChange(`${duration}${val}`)}
className="w-full"
position="popper"
>
{RELATIVE_OPTIONS.map((opt) => (
<SelectItem key={opt.unit} value={opt.unit}>
{opt.label}
</SelectItem>
))}
</Select>
</FormControl>
</div>
{error && (
<span className="text-opacity-90 text-xs text-red-600">
{error.message}
</span>
)}
</div>
</div>
);
}}
/>
)}
{selectType === MCPActivityLogDateFilterType.Absolute && (
<div className="flex h-10 w-full items-center justify-between gap-2">
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
className="relative top-2"
errorText={error?.message}
isError={Boolean(error)}
label="Start Date"
>
<DatePicker
value={field.value || undefined}
onChange={onChange}
timezone={timezone}
dateFormat="P"
buttonClassName="w-44 h-8 font-normal"
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<FontAwesomeIcon
icon={faChevronRight}
size="xs"
className="mt-6 text-mineshaft-400"
/>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
className="relative top-2"
errorText={error?.message}
isError={Boolean(error)}
label="End Date"
>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
buttonClassName="w-44 h-8 font-normal"
timezone={timezone}
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
</div>
)}
<div className="mt-8 w-full justify-end">
<Button
size="sm"
type="submit"
className="h-9 w-24 font-normal"
variant="outline_bg"
isDisabled={!formState.isDirty}
>
Apply
</Button>
</div>
</form>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={timezone}
onValueChange={(val) => setTimezone(val as Timezone)}
className="w-[10.6rem] border border-mineshaft-500! bg-mineshaft-600! capitalize"
dropdownContainerClassName="max-w-none"
position="popper"
dropdownContainerStyle={{
width: "100%"
}}
>
{Object.values(Timezone).map((tz) => (
<SelectItem value={tz} className="capitalize" key={tz}>
{tz} Timezone
</SelectItem>
))}
</Select>
</>
);
};

View File

@@ -0,0 +1,223 @@
import { Controller, useForm } from "react-hook-form";
import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
FormControl,
Input
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { mcpActivityLogFilterFormSchema, TMCPActivityLogFilterFormData } from "./types";
type Props = {
filter: TMCPActivityLogFilterFormData;
setFilter: (filter: TMCPActivityLogFilterFormData) => void;
};
type FilterItemProps = {
label: string;
onClear: () => void;
children: React.ReactNode;
};
const FilterItem = ({ label, onClear, children }: FilterItemProps) => {
return (
<div className="flex flex-col justify-between">
<div className="flex items-center pr-1">
<p className="text-xs opacity-60">{label}</p>
<Button
onClick={() => onClear()}
variant="link"
className="ml-auto font-normal text-mineshaft-400 transition-all duration-75 hover:text-mineshaft-300"
size="xs"
>
Clear
</Button>
</div>
<div>{children}</div>
</div>
);
};
const getActiveFilterCount = (filter: TMCPActivityLogFilterFormData): number => {
let count = 0;
if (filter.endpointName) count += 1;
if (filter.serverName) count += 1;
if (filter.toolName) count += 1;
if (filter.actor) count += 1;
return count;
};
export const MCPActivityLogsFilter = ({ filter, setFilter }: Props) => {
const { control, handleSubmit, setValue, formState } = useForm<TMCPActivityLogFilterFormData>({
resolver: zodResolver(mcpActivityLogFilterFormSchema),
values: filter
});
const activeFilterCount = getActiveFilterCount(filter);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline_bg" colorSchema="primary" className="relative">
<FontAwesomeIcon icon={faFilterCircleXmark} className="mr-2" />
Filter
{activeFilterCount > 0 && (
<Badge className="absolute -top-2 -right-2" variant="info">
{activeFilterCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="mt-4 overflow-visible py-4">
<form onSubmit={handleSubmit(setFilter)}>
<div className="flex max-w-96 min-w-80 flex-col font-inter">
<div className="mb-3 flex items-center border-b border-b-mineshaft-500 px-3 pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<span>Filters</span>
<Badge isSquare variant="info">
{activeFilterCount}
</Badge>
</div>
<Button
onClick={() => {
setFilter({
endpointName: undefined,
serverName: undefined,
toolName: undefined,
actor: undefined
});
}}
variant="link"
className="text-mineshaft-400"
size="xs"
>
Clear filters
</Button>
</div>
</div>
<div className="space-y-3 px-3">
<FilterItem
label="Endpoint"
onClear={() => {
setValue("endpointName", undefined, { shouldDirty: true });
}}
>
<Controller
control={control}
name="endpointName"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder="Filter by endpoint name"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
</FilterItem>
<FilterItem
label="Server"
onClear={() => {
setValue("serverName", undefined, { shouldDirty: true });
}}
>
<Controller
control={control}
name="serverName"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder="Filter by server name"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
</FilterItem>
<FilterItem
label="Tool"
onClear={() => {
setValue("toolName", undefined, { shouldDirty: true });
}}
>
<Controller
control={control}
name="toolName"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder="Filter by tool name"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
</FilterItem>
<FilterItem
label="User"
onClear={() => {
setValue("actor", undefined, { shouldDirty: true });
}}
>
<Controller
control={control}
name="actor"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Input
value={value || ""}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder="Filter by user"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
</FilterItem>
</div>
<div className="mt-4 px-3">
<Button size="xs" type="submit" isDisabled={!formState.isDirty}>
Apply
</Button>
</div>
</div>
</form>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,126 @@
import { Fragment, useState } from "react";
import { faFile } from "@fortawesome/free-solid-svg-icons";
import ms from "ms";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import { Timezone } from "@app/helpers/datetime";
import { useListAiMcpActivityLogs } from "@app/hooks/api";
import { MCPActivityLogsDateFilter } from "./MCPActivityLogsDateFilter";
import { MCPActivityLogsFilter } from "./MCPActivityLogsFilter";
import { MCPActivityLogsTableRow } from "./MCPActivityLogsTableRow";
import {
MCPActivityLogDateFilterType,
TMCPActivityLogDateFilterFormData,
TMCPActivityLogFilterFormData
} from "./types";
const MCP_ACTIVITY_LOG_LIMIT = 30;
export const MCPActivityLogsTab = () => {
const { currentProject } = useProject();
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
const [logFilter, setLogFilter] = useState<TMCPActivityLogFilterFormData>({
endpointName: undefined,
serverName: undefined,
toolName: undefined,
actor: undefined
});
const [dateFilter, setDateFilter] = useState<TMCPActivityLogDateFilterFormData>({
startDate: new Date(Number(new Date()) - ms("1h")),
endDate: new Date(),
type: MCPActivityLogDateFilterType.Relative,
relativeModeValue: "1h"
});
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useListAiMcpActivityLogs({
projectId: currentProject?.id || "",
limit: MCP_ACTIVITY_LOG_LIMIT,
startDate: dateFilter.startDate,
endDate: dateFilter.endDate,
endpointName: logFilter.endpointName,
serverName: logFilter.serverName,
toolName: logFilter.toolName,
actor: logFilter.actor
});
// Flatten pages into a single array
const activityLogs = data?.pages?.flat() ?? [];
const isEmpty = !isPending && activityLogs.length === 0;
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-x-2 whitespace-nowrap">
<p className="text-xl font-medium text-mineshaft-100">Activity Logs</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<MCPActivityLogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
<MCPActivityLogsFilter filter={logFilter} setFilter={setLogFilter} />
</div>
</div>
<div className="space-y-2">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-8" />
<Th className="w-48">Timestamp</Th>
<Th className="w-56">Endpoint</Th>
<Th className="w-48">Tool</Th>
<Th>User</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={5} innerKey="mcp-activity-logs" />}
{!isPending &&
activityLogs.map((log) => (
<Fragment key={`mcp-activity-log-${log.id}`}>
<MCPActivityLogsTableRow activityLog={log} />
</Fragment>
))}
{isEmpty && (
<Tr>
<td colSpan={5}>
<EmptyState title="No activity logs found" icon={faFile} />
</td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 px-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of logs"}
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { faCaretDown, faCaretRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TAiMcpActivityLog } from "@app/hooks/api";
type Props = {
activityLog: TAiMcpActivityLog;
};
const formatTimestamp = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const ToolBadge = ({ tool }: { tool: string }) => (
<span className="inline-flex items-center rounded bg-mineshaft-600 px-2 py-0.5 font-mono text-xs text-mineshaft-200">
{tool}
</span>
);
export const MCPActivityLogsTableRow = ({ activityLog }: Props) => {
const [isOpen, setIsOpen] = useToggle();
return (
<>
<Tr
className="h-10 cursor-pointer border-x-0 border-t-0 border-b hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setIsOpen.toggle()}
onKeyDown={(evt) => {
if (evt.key === "Enter") setIsOpen.toggle();
}}
isHoverable
>
<Td className="flex items-center gap-2 pr-0 align-top">
<FontAwesomeIcon icon={isOpen ? faCaretDown : faCaretRight} />
</Td>
<Td className="align-top font-mono text-sm whitespace-nowrap text-mineshaft-300">
{formatTimestamp(activityLog.createdAt)}
</Td>
<Td className="align-top text-mineshaft-200">{activityLog.endpointName}</Td>
<Td className="align-top">
<ToolBadge tool={activityLog.toolName} />
</Td>
<Td className="align-top text-mineshaft-300">{activityLog.actor}</Td>
</Tr>
{isOpen && (
<Tr className={`log-${activityLog.id} h-10 border-x-0 border-t-0 border-b`}>
<Td colSpan={5} className="px-3 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="mb-2 text-sm font-medium text-mineshaft-200">Request</h4>
<div className="h-80 thin-scrollbar overflow-auto rounded-md border border-mineshaft-600 bg-bunker-800 p-3 font-mono text-xs leading-5 whitespace-pre-wrap text-mineshaft-300">
{JSON.stringify(activityLog.request, null, 2)}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-mineshaft-200">Response</h4>
<div className="h-80 thin-scrollbar overflow-auto rounded-md border border-mineshaft-600 bg-bunker-800 p-3 font-mono text-xs leading-5 whitespace-pre-wrap text-mineshaft-300">
{JSON.stringify(activityLog.response, null, 2)}
</div>
</div>
</div>
</Td>
</Tr>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export { MCPActivityLogsTab } from "./MCPActivityLogsTab";

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export type TMCPActivityLog = {
id: string;
projectId: string;
endpointName: string;
serverName: string;
toolName: string;
actor: string;
request: unknown;
response: unknown;
createdAt: string;
updatedAt: string;
};
export enum MCPActivityLogDateFilterType {
Relative = "relative",
Absolute = "absolute"
}
export const mcpActivityLogFilterFormSchema = z.object({
endpointName: z.string().optional(),
serverName: z.string().optional(),
toolName: z.string().optional(),
actor: z.string().optional()
});
export const mcpActivityLogDateFilterFormSchema = z
.object({
startDate: z.date(),
endDate: z.date(),
type: z.nativeEnum(MCPActivityLogDateFilterType),
relativeModeValue: z.string().optional()
})
.superRefine((el, ctx) => {
if (el.type === MCPActivityLogDateFilterType.Absolute && el.startDate > el.endDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "End date cannot be before start date"
});
}
});
export type TMCPActivityLogFilterFormData = z.infer<typeof mcpActivityLogFilterFormSchema>;
export type TMCPActivityLogDateFilterFormData = z.infer<typeof mcpActivityLogDateFilterFormSchema>;

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
export const AddMCPEndpointFormSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(64, "Name cannot exceed 64 characters"),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").optional(),
serverIds: z.array(z.string().uuid()).default([])
});
export type TAddMCPEndpointForm = z.infer<typeof AddMCPEndpointFormSchema>;

Some files were not shown because too many files have changed in this diff Show More