Improve MCP settings auth flow, remote config support, and diagnostics UX (#953)
* feat: improve MCP settings auth workflow * fix: complete MCP settings auth flow * fix: harden MCP settings auth flow * fix: add MCP settings refresh control * fix: stabilize MCP authorization and status handling * fix: clarify MCP advanced remote options toggle * fix: improve MCP import and diagnostics * feat: improve MCP settings panel visual hierarchy and UX * fix: expose MCP auth actions in connected state * fix: remove MCP import snippet helper text * fix: address MCP review feedback * fix: correct MCP page transport layout after rebase
This commit is contained in:
Generated
+271
-10
@@ -5,21 +5,100 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.17"
|
||||
"@opencode-ai/plugin": "1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
|
||||
"integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.10.tgz",
|
||||
"integrity": "sha512-35Za2LT2oNWnBoonmPjN1Z9PB4+ir2a6GbZ3nIZQQL/96mqzTRkT1FqUkQc3bdMmfT1R1rqOd5aMzkIXMqC7dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.3.17",
|
||||
"@opencode-ai/sdk": "1.4.10",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.96",
|
||||
"@opentui/solid": ">=0.1.96"
|
||||
"@opentui/core": ">=0.1.100",
|
||||
"@opentui/solid": ">=0.1.100"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -31,14 +110,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
|
||||
"integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.10.tgz",
|
||||
"integrity": "sha512-Yaddcs/COp0hwiCxgobSZyDUN0nHgkEFL4bG0BQxwd52SGAysOr6A6L0ihfkuhVx0kbi9eXWgZk4ydNOrnur5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -53,12 +138,135 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.6.0.tgz",
|
||||
"integrity": "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.9",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.9.tgz",
|
||||
"integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -68,6 +276,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -89,6 +313,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -104,6 +350,21 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.13",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@heroui/scroll-shadow": "^2.3.18",
|
||||
@@ -136,13 +136,13 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.13",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
+2
-2
@@ -81,11 +81,11 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.13",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@heroui/scroll-shadow": "^2.3.18",
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.13",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -53,6 +53,8 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
||||
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
||||
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||
|
||||
const AboutDialogWrapper: React.FC = () => {
|
||||
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
||||
@@ -105,6 +107,14 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
||||
};
|
||||
};
|
||||
|
||||
const isMcpOAuthCallbackPath = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
|
||||
};
|
||||
|
||||
const EmbeddedSessionSelectionGate: React.FC<{
|
||||
embeddedSessionChat: EmbeddedSessionChatConfig | null;
|
||||
isVSCodeRuntime: boolean;
|
||||
@@ -192,6 +202,7 @@ function App({ apis }: AppProps) {
|
||||
const appReadyDispatchedRef = React.useRef(false);
|
||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStreamPerfEnabled(showMemoryDebug);
|
||||
@@ -684,6 +695,14 @@ function App({ apis }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isMcpOAuthCallback) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<McpOAuthCallbackPage />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// VS Code runtime - simplified layout without git/terminal views
|
||||
if (isVSCodeRuntime) {
|
||||
// Check if this is the Agent Manager panel
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { computeMcpHealth, useMcpStore } from '@/stores/useMcpStore';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
@@ -66,6 +67,8 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
const refresh = useMcpStore((state) => state.refresh);
|
||||
const connect = useMcpStore((state) => state.connect);
|
||||
const disconnect = useMcpStore((state) => state.disconnect);
|
||||
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
|
||||
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
|
||||
const [isSpinning, setIsSpinning] = React.useState(false);
|
||||
const [busyName, setBusyName] = React.useState<string | null>(null);
|
||||
|
||||
@@ -73,14 +76,27 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
void refresh({ directory, silent: true });
|
||||
}, [refresh, directory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMcpConfigs({ force: true });
|
||||
}, [loadMcpConfigs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
void refresh({ directory, silent: true });
|
||||
}, [active, refresh, directory]);
|
||||
void Promise.all([
|
||||
refresh({ directory, silent: true }),
|
||||
loadMcpConfigs({ force: true }),
|
||||
]);
|
||||
}, [active, refresh, directory, loadMcpConfigs]);
|
||||
|
||||
const sortedNames = React.useMemo(() => {
|
||||
return Object.keys(status).sort((a, b) => a.localeCompare(b));
|
||||
}, [status]);
|
||||
const names = new Set<string>(Object.keys(status));
|
||||
for (const server of mcpServers) {
|
||||
if (server?.name) {
|
||||
names.add(server.name);
|
||||
}
|
||||
}
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}, [mcpServers, status]);
|
||||
|
||||
const handleRefresh = React.useCallback((e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -195,6 +211,8 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
|
||||
const refresh = useMcpStore((state) => state.refresh);
|
||||
const connect = useMcpStore((state) => state.connect);
|
||||
const disconnect = useMcpStore((state) => state.disconnect);
|
||||
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
|
||||
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
|
||||
|
||||
const handleDropdownOpenChange = React.useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
@@ -219,19 +237,29 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
|
||||
// Fetch on mount and when directory changes
|
||||
React.useEffect(() => {
|
||||
void refresh({ directory, silent: true });
|
||||
}, [refresh, directory]);
|
||||
void loadMcpConfigs({ force: true });
|
||||
}, [refresh, directory, loadMcpConfigs]);
|
||||
|
||||
// Refresh when dropdown opens
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void refresh({ directory, silent: true });
|
||||
}, [open, refresh, directory]);
|
||||
void Promise.all([
|
||||
refresh({ directory, silent: true }),
|
||||
loadMcpConfigs({ force: true }),
|
||||
]);
|
||||
}, [open, refresh, directory, loadMcpConfigs]);
|
||||
|
||||
const health = React.useMemo(() => computeMcpHealth(status), [status]);
|
||||
|
||||
const sortedNames = React.useMemo(() => {
|
||||
return Object.keys(status).sort((a, b) => a.localeCompare(b));
|
||||
}, [status]);
|
||||
const names = new Set<string>(Object.keys(status));
|
||||
for (const server of mcpServers) {
|
||||
if (server?.name) {
|
||||
names.add(server.name);
|
||||
}
|
||||
}
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}, [mcpServers, status]);
|
||||
|
||||
const handleRefresh = React.useCallback((e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
|
||||
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
|
||||
const value = params.get(key);
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
const normalizeMcpAuthErrorMessage = (error: unknown, fallback: string): string => {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
if (/oauth state required/i.test(message)) {
|
||||
return 'Authorization session expired or was cleared during reload. Return to OpenChamber and click Authorize again.';
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
export const McpOAuthCallbackPage: React.FC = () => {
|
||||
const completeAuth = useMcpStore((state) => state.completeAuth);
|
||||
const [status, setStatus] = React.useState<'working' | 'success' | 'error'>('working');
|
||||
const [message, setMessage] = React.useState('Completing MCP authorization...');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
setStatus('error');
|
||||
setMessage('Browser context unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = parseQueryParam(params, 'code');
|
||||
const callbackContext = parseMcpOAuthCallbackContext(params);
|
||||
const callbackStateKey = parseMcpOAuthCallbackStateKey(params);
|
||||
const error = parseQueryParam(params, 'error');
|
||||
const errorDescription = parseQueryParam(params, 'error_description');
|
||||
|
||||
if (error) {
|
||||
if (callbackStateKey) {
|
||||
void fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('error');
|
||||
setMessage(errorDescription ?? error);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!code) {
|
||||
throw new Error('Missing OAuth authorization code. Start authorization again from MCP Settings or paste the returned code into OpenChamber manually.');
|
||||
}
|
||||
|
||||
let pendingContext = callbackContext;
|
||||
if (!pendingContext && callbackStateKey) {
|
||||
const response = await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
|
||||
if (payload?.name?.trim()) {
|
||||
pendingContext = {
|
||||
name: payload.name.trim(),
|
||||
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingContext?.name) {
|
||||
throw new Error('Authorization session details were not available. Start authorization again from MCP Settings or paste the returned code into OpenChamber manually.');
|
||||
}
|
||||
|
||||
await completeAuth(pendingContext.name, code, pendingContext.directory);
|
||||
if (callbackStateKey) {
|
||||
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('success');
|
||||
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
|
||||
} catch (authError) {
|
||||
if (callbackStateKey) {
|
||||
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('error');
|
||||
setMessage(normalizeMcpAuthErrorMessage(authError, 'Failed to complete MCP authorization.'));
|
||||
}
|
||||
})();
|
||||
}, [completeAuth]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6 py-12 text-foreground">
|
||||
<div className="w-full max-w-xl rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-8 shadow-sm">
|
||||
<div className="space-y-3 text-center">
|
||||
<div
|
||||
className={status === 'error' ? 'text-[var(--status-error)]' : status === 'success' ? 'text-[var(--status-success)]' : 'text-[var(--status-info)]'}
|
||||
>
|
||||
<h1 className="typography-hero font-semibold">
|
||||
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="typography-body text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
|
||||
{status !== 'working' && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.location.replace('/');
|
||||
}}
|
||||
>
|
||||
Return to OpenChamber
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react';
|
||||
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -66,10 +66,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [openMenuMcp, setOpenMenuMcp] = React.useState<string | null>(null);
|
||||
const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false);
|
||||
|
||||
const projectServers = React.useMemo(
|
||||
() => mcpServers.filter((server) => server.scope === 'project'),
|
||||
@@ -84,6 +87,25 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
void loadMcpConfigs();
|
||||
}, [loadMcpConfigs]);
|
||||
|
||||
const handleRefresh = React.useCallback(() => {
|
||||
if (isRefreshingStatus) return;
|
||||
|
||||
setIsRefreshingStatus(true);
|
||||
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
Promise.all([
|
||||
refreshStatus({ directory: currentDirectory, silent: true }),
|
||||
minSpinPromise,
|
||||
]).then(() => {
|
||||
const error = getErrorForDirectory(currentDirectory);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
}).finally(() => {
|
||||
setIsRefreshingStatus(false);
|
||||
});
|
||||
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
const baseName = 'new-mcp-server';
|
||||
let newName = baseName;
|
||||
@@ -100,6 +122,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
command: [],
|
||||
url: '',
|
||||
environment: [],
|
||||
headers: [],
|
||||
oauthEnabled: true,
|
||||
oauthClientId: '',
|
||||
oauthClientSecret: '',
|
||||
oauthScope: '',
|
||||
oauthRedirectUri: '',
|
||||
timeout: '',
|
||||
enabled: true,
|
||||
};
|
||||
setMcpDraft(draft);
|
||||
@@ -110,9 +139,15 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
const success = await deleteMcp(deleteTarget.name);
|
||||
if (success) {
|
||||
toast.success(`MCP server "${deleteTarget.name}" deleted`);
|
||||
const result = await deleteMcp(deleteTarget.name);
|
||||
if (result.ok) {
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
|
||||
description: result.warning || 'Refresh the MCP list if the UI looks stale.',
|
||||
});
|
||||
} else {
|
||||
toast.success(result.message || `MCP server "${deleteTarget.name}" deleted`);
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to delete MCP server');
|
||||
}
|
||||
@@ -123,7 +158,19 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<h2 className="text-base font-semibold text-foreground mb-3">MCP Servers</h2>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">MCP Servers</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isRefreshingStatus}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh MCP status"
|
||||
title="Refresh MCP status"
|
||||
>
|
||||
<RiRefreshLine className={cn('h-4 w-4', isRefreshingStatus && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
@@ -184,8 +231,12 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{server.type}
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
<RiGlobalLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
|
||||
@@ -254,8 +305,12 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={tone} enabled={server.enabled} />
|
||||
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
{server.type}
|
||||
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
|
||||
{server.type === 'local' ? (
|
||||
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
) : (
|
||||
<RiGlobalLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { McpDraft } from '@/stores/useMcpConfigStore';
|
||||
|
||||
export interface ImportedMcpResult {
|
||||
readonly ok: true;
|
||||
readonly name?: string;
|
||||
readonly type: 'local' | 'remote';
|
||||
readonly command: string[];
|
||||
readonly url: string;
|
||||
readonly environment: Array<{ key: string; value: string }>;
|
||||
readonly headers: Array<{ key: string; value: string }>;
|
||||
readonly oauthEnabled: boolean;
|
||||
readonly oauthClientId: string;
|
||||
readonly oauthClientSecret: string;
|
||||
readonly oauthScope: string;
|
||||
readonly oauthRedirectUri: string;
|
||||
readonly timeout: string;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export type ImportedMcpError =
|
||||
| { readonly ok: false; readonly error: string }
|
||||
| { readonly ok: false; readonly error: string; readonly parsed: unknown };
|
||||
|
||||
export type ImportedMcpOutcome = ImportedMcpResult | ImportedMcpError;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
function buildError(message: string, parsed?: unknown): ImportedMcpError {
|
||||
return parsed !== undefined
|
||||
? { ok: false, error: message, parsed }
|
||||
: { ok: false, error: message };
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
name: string | undefined,
|
||||
type: 'local' | 'remote',
|
||||
raw: Record<string, unknown>,
|
||||
): ImportedMcpResult {
|
||||
const command: string[] = buildCommand(raw);
|
||||
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
|
||||
|
||||
const environment = buildEnv(raw, 'env', 'environment');
|
||||
const headers = buildEnv(raw, 'headers');
|
||||
|
||||
const oauthEnabled = buildOAuthEnabled(raw);
|
||||
const oauthClientId = typeof raw.oauth === 'object' && raw.oauth !== null
|
||||
? String((raw.oauth as Record<string, unknown>).clientId ?? '').trim()
|
||||
: '';
|
||||
const oauthClientSecret = typeof raw.oauth === 'object' && raw.oauth !== null
|
||||
? String((raw.oauth as Record<string, unknown>).clientSecret ?? '').trim()
|
||||
: '';
|
||||
const oauthScope = typeof raw.oauth === 'object' && raw.oauth !== null
|
||||
? String((raw.oauth as Record<string, unknown>).scope ?? '').trim()
|
||||
: '';
|
||||
const oauthRedirectUri = typeof raw.oauth === 'object' && raw.oauth !== null
|
||||
? String((raw.oauth as Record<string, unknown>).redirectUri ?? '').trim()
|
||||
: '';
|
||||
|
||||
const timeout = buildTimeout(raw);
|
||||
|
||||
const enabled = buildEnabled(raw);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
name,
|
||||
type,
|
||||
command,
|
||||
url,
|
||||
environment,
|
||||
headers,
|
||||
oauthEnabled,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthScope,
|
||||
oauthRedirectUri,
|
||||
timeout,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCommand(raw: Record<string, unknown>): string[] {
|
||||
const cmd = raw.command;
|
||||
const args = raw.args;
|
||||
|
||||
if (stringArray(cmd) && stringArray(args)) {
|
||||
return [...cmd, ...args];
|
||||
}
|
||||
if (stringArray(cmd)) {
|
||||
return cmd;
|
||||
}
|
||||
if (typeof cmd === 'string' && cmd.trim()) {
|
||||
const parts = cmd.trim().split(/\s+/);
|
||||
if (stringArray(args)) {
|
||||
return [...parts, ...args];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
if (stringArray(args)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildEnv(
|
||||
raw: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): Array<{ key: string; value: string }> {
|
||||
for (const key of keys) {
|
||||
const val = raw[key];
|
||||
if (!isObject(val)) continue;
|
||||
|
||||
const entries = Object.entries(val as Record<string, unknown>).filter(
|
||||
([k, v]) => k && typeof v === 'string',
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
return entries.map(([k, v]) => ({ key: k, value: String(v) }));
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildOAuthEnabled(raw: Record<string, unknown>): boolean {
|
||||
if (raw.oauth === false || raw.oauth === null || raw.oauth === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(raw.oauth)) {
|
||||
return false;
|
||||
}
|
||||
const oauth = raw.oauth as Record<string, unknown>;
|
||||
return !!(
|
||||
(typeof oauth.clientId === 'string' && oauth.clientId.trim()) ||
|
||||
(typeof oauth.clientSecret === 'string' && oauth.clientSecret.trim()) ||
|
||||
(typeof oauth.scope === 'string' && oauth.scope.trim()) ||
|
||||
(typeof oauth.redirectUri === 'string' && oauth.redirectUri.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function buildTimeout(raw: Record<string, unknown>): string {
|
||||
const t = raw.timeout;
|
||||
if (typeof t === 'number' && Number.isFinite(t) && t > 0) {
|
||||
return String(Math.floor(t));
|
||||
}
|
||||
if (typeof t === 'string' && t.trim()) {
|
||||
const n = Number(t);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return String(Math.floor(n));
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildEnabled(raw: Record<string, unknown>): boolean {
|
||||
if ('disabled' in raw && raw.disabled === true) {
|
||||
return false;
|
||||
}
|
||||
if ('enabled' in raw) {
|
||||
return Boolean(raw.enabled);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single named server entry from a parsed JSON object.
|
||||
* Returns null if the shape does not contain exactly one identifiable server.
|
||||
*/
|
||||
function extractSingleServer(
|
||||
obj: Record<string, unknown>,
|
||||
): { name: string; entry: Record<string, unknown> } | null {
|
||||
const mcpServers = obj.mcpServers;
|
||||
if (isObject(mcpServers)) {
|
||||
const keys = Object.keys(mcpServers);
|
||||
if (keys.length === 1) {
|
||||
const name = keys[0]!;
|
||||
const entry = mcpServers[name];
|
||||
if (isObject(entry)) {
|
||||
return { name, entry };
|
||||
}
|
||||
}
|
||||
if (keys.length > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const serverKeys = Object.keys(obj).filter((k) => {
|
||||
const v = obj[k];
|
||||
return k !== 'mcpServers' && isObject(v);
|
||||
});
|
||||
|
||||
if (serverKeys.length === 1) {
|
||||
const name = serverKeys[0]!;
|
||||
const entry = obj[name] as Record<string, unknown>;
|
||||
if (isServerConfig(entry)) {
|
||||
return { name, entry };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isServerConfig(val: Record<string, unknown>): boolean {
|
||||
return (
|
||||
val.type === 'local' ||
|
||||
val.type === 'remote' ||
|
||||
Array.isArray(val.command) ||
|
||||
typeof val.url === 'string' ||
|
||||
Array.isArray(val.args)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw JSON string as an MCP server snippet and return normalized result
|
||||
* or a structured error. Does not mutate the current draft — caller applies
|
||||
* the result to form state.
|
||||
*
|
||||
* Supported shapes:
|
||||
* { "mcpServers": { "name": { ... } } }
|
||||
* { "name": { ... } }
|
||||
* { ...serverConfig }
|
||||
*/
|
||||
export function parseImportedMcpSnippet(
|
||||
raw: string,
|
||||
options?: { fallbackName?: string },
|
||||
): ImportedMcpOutcome {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return buildError('No JSON content provided');
|
||||
}
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
return buildError(
|
||||
err instanceof Error ? `Invalid JSON: ${err.message}` : 'Invalid JSON',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isObject(parsed)) {
|
||||
return buildError('Expected a JSON object, not an array or primitive');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Detect single named entry inside { "mcpServers": { "name": { ... } } }
|
||||
const mcpServers = obj.mcpServers;
|
||||
if (isObject(mcpServers)) {
|
||||
const keys = Object.keys(mcpServers);
|
||||
if (keys.length === 0) {
|
||||
return buildError('mcpServers object is empty', parsed);
|
||||
}
|
||||
if (keys.length > 1) {
|
||||
return buildError(
|
||||
'Paste one server at a time. Found ' +
|
||||
keys.length +
|
||||
' servers in mcpServers',
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
const serverName = keys[0]!;
|
||||
const entry = mcpServers[serverName];
|
||||
if (!isObject(entry)) {
|
||||
return buildError('Server entry is not a valid object', parsed);
|
||||
}
|
||||
return buildResult(serverName, inferType(entry as Record<string, unknown>), entry as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Detect single named entry { "serverName": { ... } }
|
||||
const single = extractSingleServer(obj);
|
||||
if (single) {
|
||||
return buildResult(
|
||||
single.name,
|
||||
inferType(single.entry),
|
||||
single.entry,
|
||||
);
|
||||
}
|
||||
|
||||
// Treat top-level as a bare server config
|
||||
if (isServerConfig(obj)) {
|
||||
const type = inferType(obj);
|
||||
const name = typeof obj.name === 'string' && obj.name.trim()
|
||||
? obj.name.trim()
|
||||
: options?.fallbackName;
|
||||
return buildResult(name, type, obj);
|
||||
}
|
||||
|
||||
return buildError(
|
||||
'No recognizable MCP server configuration found in JSON',
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
|
||||
function inferType(entry: Record<string, unknown>): 'local' | 'remote' {
|
||||
if (entry.type === 'remote') return 'remote';
|
||||
if (entry.type === 'local') return 'local';
|
||||
if (typeof entry.url === 'string' && entry.url.trim()) return 'remote';
|
||||
if (Array.isArray(entry.command) || typeof entry.command === 'string') return 'local';
|
||||
if (Array.isArray(entry.args)) return 'local';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an imported result to a new or existing McpDraft.
|
||||
* All fields from the result override the draft, but fields only
|
||||
* relevant to the opposite transport type are cleared.
|
||||
*/
|
||||
export function applyImportedMcpToDraft(
|
||||
result: ImportedMcpResult,
|
||||
currentDraft: Partial<McpDraft> & { name?: string },
|
||||
options?: { isNewServer?: boolean },
|
||||
): Partial<McpDraft> & { name: string } {
|
||||
const isNew = options?.isNewServer ?? false;
|
||||
const importedName = result.name;
|
||||
const name = isNew && importedName ? importedName : currentDraft.name ?? '';
|
||||
|
||||
const draft: Partial<McpDraft> & { name: string } = {
|
||||
...currentDraft,
|
||||
name,
|
||||
type: result.type,
|
||||
command: result.type === 'local' ? result.command : [],
|
||||
url: result.type === 'remote' ? result.url : '',
|
||||
environment: result.environment,
|
||||
headers: result.type === 'remote' ? result.headers : [],
|
||||
oauthEnabled: result.type === 'remote' ? result.oauthEnabled : false,
|
||||
oauthClientId: result.type === 'remote' ? result.oauthClientId : '',
|
||||
oauthClientSecret: result.type === 'remote' ? result.oauthClientSecret : '',
|
||||
oauthScope: result.type === 'remote' ? result.oauthScope : '',
|
||||
oauthRedirectUri: result.type === 'remote' ? result.oauthRedirectUri : '',
|
||||
timeout: result.type === 'remote' ? result.timeout : '',
|
||||
enabled: result.enabled,
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export const MCP_OAUTH_CALLBACK_PATH = '/mcp/oauth/callback';
|
||||
|
||||
type McpOAuthStatePayload = {
|
||||
v: 1;
|
||||
n: string;
|
||||
d: string | null;
|
||||
};
|
||||
|
||||
const decodeBase64Url = (value: string): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
||||
const binary = window.atob(normalized + padding);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseMcpOAuthCallbackContext = (params: URLSearchParams): {
|
||||
name: string;
|
||||
directory: string | null;
|
||||
} | null => {
|
||||
const stateContext = parseMcpOAuthState(params.get('state'));
|
||||
if (stateContext) {
|
||||
return stateContext;
|
||||
}
|
||||
|
||||
const server = params.get('server');
|
||||
if (typeof server !== 'string' || !server.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directory = params.get('directory');
|
||||
return {
|
||||
name: server.trim(),
|
||||
directory: typeof directory === 'string' && directory.trim() ? directory.trim() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseMcpOAuthCallbackStateKey = (params: URLSearchParams): string | null => {
|
||||
const rawState = params.get('state');
|
||||
if (typeof rawState !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = rawState.trim();
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
export const parseMcpOAuthState = (raw: string | null | undefined): {
|
||||
name: string;
|
||||
directory: string | null;
|
||||
} | null => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = decodeBase64Url(raw.trim());
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decoded) as Partial<McpOAuthStatePayload>;
|
||||
if (payload?.v !== 1 || typeof payload.n !== 'string' || !payload.n.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: payload.n.trim(),
|
||||
directory: typeof payload.d === 'string' && payload.d.trim() ? payload.d.trim() : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,13 @@ import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
export type McpScope = 'user' | 'project';
|
||||
|
||||
type McpMutationResult = {
|
||||
ok: boolean;
|
||||
reloadFailed?: boolean;
|
||||
message?: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
const getConfigDirectory = (): string | null => {
|
||||
try {
|
||||
const projectsStore = useProjectsStore.getState();
|
||||
@@ -38,10 +45,20 @@ export interface McpLocalConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface McpOAuthConfig {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
export interface McpRemoteConfig {
|
||||
type: 'remote';
|
||||
url: string;
|
||||
environment?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
oauth?: McpOAuthConfig | false;
|
||||
timeout?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -55,6 +72,13 @@ export interface McpDraft {
|
||||
command: string[];
|
||||
url: string;
|
||||
environment: Array<{ key: string; value: string }>;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
oauthEnabled: boolean;
|
||||
oauthClientId: string;
|
||||
oauthClientSecret: string;
|
||||
oauthScope: string;
|
||||
oauthRedirectUri: string;
|
||||
timeout: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -71,6 +95,12 @@ export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Re
|
||||
return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value]));
|
||||
};
|
||||
|
||||
const trimOptionalString = (value: string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
const MCP_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_MCP_CACHE_KEY = '__default__';
|
||||
@@ -91,13 +121,17 @@ interface McpConfigStore {
|
||||
|
||||
setSelectedMcp: (name: string | null) => void;
|
||||
setMcpDraft: (draft: McpDraft | null) => void;
|
||||
loadMcpConfigs: () => Promise<boolean>;
|
||||
createMcp: (config: McpDraft) => Promise<boolean>;
|
||||
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<boolean>;
|
||||
deleteMcp: (name: string) => Promise<boolean>;
|
||||
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
|
||||
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
|
||||
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
|
||||
deleteMcp: (name: string) => Promise<McpMutationResult>;
|
||||
getMcpByName: (name: string) => McpServerWithScope | undefined;
|
||||
}
|
||||
|
||||
const invalidateMcpCache = (directory: string | null) => {
|
||||
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
|
||||
};
|
||||
|
||||
export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -111,19 +145,19 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
|
||||
setMcpDraft: (draft) => set({ mcpDraft: draft }),
|
||||
|
||||
loadMcpConfigs: async () => {
|
||||
loadMcpConfigs: async (options) => {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const cacheKey = getMcpCacheKey(configDirectory);
|
||||
const now = Date.now();
|
||||
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedConfigs = get().mcpServers.length > 0;
|
||||
|
||||
if (hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
|
||||
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inFlight = mcpLoadInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
if (!options?.force && inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
@@ -177,6 +211,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
throw new Error(payload?.error || 'Failed to create MCP server');
|
||||
}
|
||||
|
||||
invalidateMcpCache(configDirectory);
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
@@ -184,14 +220,25 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
message: payload?.message,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
message: payload?.message,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to create MCP:', error);
|
||||
return false;
|
||||
return { ok: false };
|
||||
} finally {
|
||||
if (!requiresReload) finishConfigUpdate();
|
||||
}
|
||||
@@ -218,6 +265,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
throw new Error(payload?.error || 'Failed to update MCP server');
|
||||
}
|
||||
|
||||
invalidateMcpCache(configDirectory);
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
@@ -225,11 +274,22 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
message: payload?.message,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
message: payload?.message,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to update MCP:', error);
|
||||
throw error;
|
||||
@@ -254,6 +314,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
throw new Error(payload?.error || 'Failed to delete MCP server');
|
||||
}
|
||||
|
||||
invalidateMcpCache(configDirectory);
|
||||
|
||||
if (payload?.requiresReload) {
|
||||
requiresReload = true;
|
||||
await refreshAfterOpenCodeRestart({
|
||||
@@ -261,17 +323,21 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (get().selectedMcpName === name) {
|
||||
set({ selectedMcpName: null });
|
||||
}
|
||||
await get().loadMcpConfigs();
|
||||
return true;
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
message: payload?.message,
|
||||
warning: payload?.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to delete MCP:', error);
|
||||
return false;
|
||||
return { ok: false };
|
||||
} finally {
|
||||
if (!requiresReload) finishConfigUpdate();
|
||||
}
|
||||
@@ -312,6 +378,46 @@ function buildMcpBody(config: Partial<McpDraft>): Record<string, unknown> {
|
||||
body.environment = envArrayToRecord(config.environment) ?? {};
|
||||
}
|
||||
|
||||
if (config.headers !== undefined) {
|
||||
body.headers = envArrayToRecord(config.headers) ?? {};
|
||||
}
|
||||
|
||||
if (
|
||||
config.oauthEnabled !== undefined ||
|
||||
config.oauthClientId !== undefined ||
|
||||
config.oauthClientSecret !== undefined ||
|
||||
config.oauthScope !== undefined ||
|
||||
config.oauthRedirectUri !== undefined
|
||||
) {
|
||||
if (config.oauthEnabled === false) {
|
||||
body.oauth = false;
|
||||
} else {
|
||||
const oauth = {
|
||||
clientId: trimOptionalString(config.oauthClientId),
|
||||
clientSecret: trimOptionalString(config.oauthClientSecret),
|
||||
scope: trimOptionalString(config.oauthScope),
|
||||
redirectUri: trimOptionalString(config.oauthRedirectUri),
|
||||
};
|
||||
|
||||
if (oauth.clientId || oauth.clientSecret || oauth.scope || oauth.redirectUri) {
|
||||
body.oauth = oauth;
|
||||
} else if (config.oauthEnabled) {
|
||||
body.oauth = {};
|
||||
} else {
|
||||
body.oauth = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.timeout !== undefined) {
|
||||
const timeout = Number(config.timeout);
|
||||
if (Number.isFinite(timeout) && timeout > 0) {
|
||||
body.timeout = timeout;
|
||||
} else {
|
||||
body.timeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.enabled !== undefined) {
|
||||
body.enabled = config.enabled;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
export type McpStatusMap = Record<string, McpStatus>;
|
||||
export type McpRuntimeDiagnostic = {
|
||||
status: 'failed';
|
||||
error: string;
|
||||
};
|
||||
export type McpRuntimeDiagnosticMap = Record<string, McpRuntimeDiagnostic>;
|
||||
|
||||
const EMPTY_STATUS: McpStatusMap = {};
|
||||
const EMPTY_DIAGNOSTICS: McpRuntimeDiagnosticMap = {};
|
||||
|
||||
type McpHealth = {
|
||||
connected: number;
|
||||
@@ -47,20 +53,34 @@ type RefreshOptions = {
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
type TestConnectionResult = {
|
||||
status?: McpStatus;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
interface McpStore {
|
||||
byDirectory: Record<string, McpStatusMap>;
|
||||
diagnosticsByDirectory: Record<string, McpRuntimeDiagnosticMap>;
|
||||
loadingKeys: Record<string, boolean>;
|
||||
lastErrorKeys: Record<string, string | null>;
|
||||
|
||||
getStatusForDirectory: (directory?: string | null) => McpStatusMap;
|
||||
getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap;
|
||||
getErrorForDirectory: (directory?: string | null) => string | null;
|
||||
refresh: (options?: RefreshOptions) => Promise<void>;
|
||||
connect: (name: string, directory?: string | null) => Promise<void>;
|
||||
disconnect: (name: string, directory?: string | null) => Promise<void>;
|
||||
startAuth: (name: string, directory?: string | null) => Promise<string>;
|
||||
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
|
||||
clearAuth: (name: string, directory?: string | null) => Promise<void>;
|
||||
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
|
||||
}
|
||||
|
||||
export const useMcpStore = create<McpStore>()(
|
||||
devtools((set, get) => ({
|
||||
byDirectory: {},
|
||||
diagnosticsByDirectory: {},
|
||||
loadingKeys: {},
|
||||
lastErrorKeys: {},
|
||||
|
||||
@@ -69,6 +89,16 @@ export const useMcpStore = create<McpStore>()(
|
||||
return get().byDirectory[key] ?? EMPTY_STATUS;
|
||||
},
|
||||
|
||||
getDiagnosticForDirectory: (directory) => {
|
||||
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
return get().diagnosticsByDirectory[key] ?? EMPTY_DIAGNOSTICS;
|
||||
},
|
||||
|
||||
getErrorForDirectory: (directory) => {
|
||||
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
return get().lastErrorKeys[key] ?? null;
|
||||
},
|
||||
|
||||
refresh: async (options) => {
|
||||
const directory = normalizeDirectory(options?.directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const key = toKey(directory);
|
||||
@@ -87,6 +117,12 @@ export const useMcpStore = create<McpStore>()(
|
||||
|
||||
set((state) => ({
|
||||
byDirectory: { ...state.byDirectory, [key]: data },
|
||||
diagnosticsByDirectory: {
|
||||
...state.diagnosticsByDirectory,
|
||||
[key]: Object.fromEntries(
|
||||
Object.entries(state.diagnosticsByDirectory[key] ?? {}).filter(([name]) => !data[name])
|
||||
),
|
||||
},
|
||||
loadingKeys: { ...state.loadingKeys, [key]: false },
|
||||
lastErrorKeys: { ...state.lastErrorKeys, [key]: null },
|
||||
}));
|
||||
@@ -101,8 +137,23 @@ export const useMcpStore = create<McpStore>()(
|
||||
|
||||
connect: async (name, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const key = toKey(normalized);
|
||||
const api = getMcpApiClient(normalized);
|
||||
await api.mcp.connect({ name }, { throwOnError: true });
|
||||
try {
|
||||
await api.mcp.connect({ name }, { throwOnError: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
set((state) => ({
|
||||
diagnosticsByDirectory: {
|
||||
...state.diagnosticsByDirectory,
|
||||
[key]: {
|
||||
...(state.diagnosticsByDirectory[key] ?? {}),
|
||||
[name]: { status: 'failed', error: message },
|
||||
},
|
||||
},
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
},
|
||||
|
||||
@@ -113,5 +164,77 @@ export const useMcpStore = create<McpStore>()(
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
},
|
||||
|
||||
startAuth: async (name, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const api = getMcpApiClient(normalized);
|
||||
const result = await api.mcp.auth.start({ name }, { throwOnError: true });
|
||||
const authorizationUrl = result.data?.authorizationUrl;
|
||||
|
||||
if (!authorizationUrl) {
|
||||
throw new Error('Authorization URL was not returned');
|
||||
}
|
||||
|
||||
return authorizationUrl;
|
||||
},
|
||||
|
||||
completeAuth: async (name, code, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const api = getMcpApiClient(normalized);
|
||||
await api.mcp.auth.callback({ name, code }, { throwOnError: true });
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
},
|
||||
|
||||
clearAuth: async (name, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const api = getMcpApiClient(normalized);
|
||||
await api.mcp.auth.remove({ name }, { throwOnError: true });
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
},
|
||||
|
||||
testConnection: async (name, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const key = toKey(normalized);
|
||||
const api = getMcpApiClient(normalized);
|
||||
const previousStatus = get().getStatusForDirectory(normalized)[name];
|
||||
const wasConnected = previousStatus?.status === 'connected';
|
||||
let errorMessage: string | undefined;
|
||||
let warningMessage: string | undefined;
|
||||
|
||||
try {
|
||||
await api.mcp.connect({ name }, { throwOnError: true });
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : 'Connection failed';
|
||||
set((state) => ({
|
||||
diagnosticsByDirectory: {
|
||||
...state.diagnosticsByDirectory,
|
||||
[key]: {
|
||||
...(state.diagnosticsByDirectory[key] ?? {}),
|
||||
[name]: { status: 'failed', error: errorMessage ?? 'Connection failed' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
const currentStatus = get().getStatusForDirectory(normalized)[name];
|
||||
const observedStatus = currentStatus;
|
||||
|
||||
if (!wasConnected && currentStatus?.status === 'connected') {
|
||||
try {
|
||||
await api.mcp.disconnect({ name }, { throwOnError: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Disconnect failed';
|
||||
warningMessage = `Connection test succeeded, but cleanup disconnect failed: ${message}`;
|
||||
}
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
}
|
||||
|
||||
return {
|
||||
status: observedStatus ?? get().getStatusForDirectory(normalized)[name],
|
||||
error: errorMessage,
|
||||
warning: warningMessage,
|
||||
};
|
||||
},
|
||||
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -20,6 +20,29 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
deleteMcpConfig,
|
||||
} = dependencies;
|
||||
|
||||
const completeMcpMutation = async (res, action, name, applyChange) => {
|
||||
applyChange();
|
||||
|
||||
try {
|
||||
await refreshOpenCodeAfterConfigChange(`mcp ${action}`);
|
||||
return res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" ${action}d. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[API:MCP ${action}] Reload failed after config write:`, error);
|
||||
return res.json({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
reloadFailed: true,
|
||||
message: `MCP server "${name}" ${action}d, but OpenCode reload failed.`,
|
||||
warning: error.message || 'OpenCode reload failed after the MCP configuration changed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/config/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
@@ -187,14 +210,8 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
}
|
||||
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
|
||||
|
||||
createMcpConfig(name, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" created. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
await completeMcpMutation(res, 'create', name, () => {
|
||||
createMcpConfig(name, config, directory, scope);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
|
||||
@@ -212,17 +229,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
}
|
||||
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
|
||||
|
||||
updateMcpConfig(name, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp update');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" updated. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
await completeMcpMutation(res, 'update', name, () => {
|
||||
updateMcpConfig(name, updates, directory);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
|
||||
if (error?.message === `MCP server "${req.params.name}" not found`) {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
|
||||
}
|
||||
});
|
||||
@@ -236,14 +250,8 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
}
|
||||
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
|
||||
|
||||
deleteMcpConfig(name, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
await completeMcpMutation(res, 'delete', name, () => {
|
||||
deleteMcpConfig(name, directory);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
|
||||
|
||||
@@ -118,6 +118,11 @@ function createMcpConfig(name, mcpConfig, workingDirectory, scope) {
|
||||
function updateMcpConfig(name, updates, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const source = getJsonEntrySource(layers, 'mcp', name);
|
||||
|
||||
if (!source.exists) {
|
||||
throw new Error(`MCP server "${name}" not found`);
|
||||
}
|
||||
|
||||
const targetPath = source.path || CONFIG_FILE;
|
||||
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
|
||||
|
||||
@@ -125,7 +130,7 @@ function updateMcpConfig(name, updates, workingDirectory) {
|
||||
config.mcp = {};
|
||||
}
|
||||
|
||||
const existing = config.mcp[name] ?? {};
|
||||
const existing = config.mcp[name];
|
||||
const { name: _ignoredName, ...updateData } = updates;
|
||||
|
||||
config.mcp[name] = buildMcpEntry({ ...existing, ...updateData });
|
||||
@@ -161,7 +166,12 @@ function deleteMcpConfig(name, workingDirectory) {
|
||||
* Build a clean MCP entry object, omitting undefined/null values
|
||||
*/
|
||||
function buildMcpEntry(data) {
|
||||
const entry = {};
|
||||
const entry = (data && typeof data === 'object' && !Array.isArray(data))
|
||||
? { ...data }
|
||||
: {};
|
||||
|
||||
delete entry.name;
|
||||
delete entry.scope;
|
||||
|
||||
// type is required
|
||||
entry.type = data.type === 'remote' ? 'remote' : 'local';
|
||||
@@ -170,11 +180,69 @@ function buildMcpEntry(data) {
|
||||
// command must be a non-empty array of strings
|
||||
if (Array.isArray(data.command) && data.command.length > 0) {
|
||||
entry.command = data.command.map(String);
|
||||
} else {
|
||||
delete entry.command;
|
||||
}
|
||||
|
||||
delete entry.url;
|
||||
delete entry.headers;
|
||||
delete entry.oauth;
|
||||
delete entry.timeout;
|
||||
} else {
|
||||
// remote: url required
|
||||
if (data.url && typeof data.url === 'string') {
|
||||
entry.url = data.url.trim();
|
||||
} else {
|
||||
delete entry.url;
|
||||
}
|
||||
|
||||
delete entry.command;
|
||||
|
||||
if (data.headers && typeof data.headers === 'object' && !Array.isArray(data.headers)) {
|
||||
const cleaned = {};
|
||||
for (const [k, v] of Object.entries(data.headers)) {
|
||||
if (k && v !== undefined && v !== null) {
|
||||
cleaned[k] = String(v);
|
||||
}
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
entry.headers = cleaned;
|
||||
} else {
|
||||
delete entry.headers;
|
||||
}
|
||||
} else if (data.headers === undefined) {
|
||||
delete entry.headers;
|
||||
}
|
||||
|
||||
if (data.oauth === false) {
|
||||
entry.oauth = false;
|
||||
} else if (data.oauth && typeof data.oauth === 'object' && !Array.isArray(data.oauth)) {
|
||||
const oauth = {};
|
||||
if (typeof data.oauth.clientId === 'string' && data.oauth.clientId.trim()) {
|
||||
oauth.clientId = data.oauth.clientId.trim();
|
||||
}
|
||||
if (typeof data.oauth.clientSecret === 'string' && data.oauth.clientSecret.trim()) {
|
||||
oauth.clientSecret = data.oauth.clientSecret.trim();
|
||||
}
|
||||
if (typeof data.oauth.scope === 'string' && data.oauth.scope.trim()) {
|
||||
oauth.scope = data.oauth.scope.trim();
|
||||
}
|
||||
if (typeof data.oauth.redirectUri === 'string' && data.oauth.redirectUri.trim()) {
|
||||
oauth.redirectUri = data.oauth.redirectUri.trim();
|
||||
}
|
||||
if (Object.keys(oauth).length > 0) {
|
||||
entry.oauth = oauth;
|
||||
} else {
|
||||
delete entry.oauth;
|
||||
}
|
||||
} else if (data.oauth === undefined) {
|
||||
delete entry.oauth;
|
||||
}
|
||||
|
||||
if (typeof data.timeout === 'number' && Number.isFinite(data.timeout) && data.timeout > 0) {
|
||||
entry.timeout = data.timeout;
|
||||
} else if (data.timeout === undefined || data.timeout === null || data.timeout === '') {
|
||||
delete entry.timeout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +256,11 @@ function buildMcpEntry(data) {
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
entry.environment = cleaned;
|
||||
} else {
|
||||
delete entry.environment;
|
||||
}
|
||||
} else if (data.environment === undefined) {
|
||||
delete entry.environment;
|
||||
}
|
||||
|
||||
// enabled defaults to true
|
||||
|
||||
@@ -18,6 +18,8 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
} = dependencies;
|
||||
|
||||
let authLibrary = null;
|
||||
const pendingMcpAuthContextByState = new Map();
|
||||
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
|
||||
const getAuthLibrary = async () => {
|
||||
if (!authLibrary) {
|
||||
authLibrary = await import('./auth.js');
|
||||
@@ -25,6 +27,24 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
return authLibrary;
|
||||
};
|
||||
|
||||
const normalizePendingString = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
const pruneExpiredPendingMcpAuthContexts = () => {
|
||||
const now = Date.now();
|
||||
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
|
||||
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
|
||||
pendingMcpAuthContextByState.delete(state);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/config/settings', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
@@ -59,6 +79,76 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mcp/auth/pending', async (req, res) => {
|
||||
try {
|
||||
pruneExpiredPendingMcpAuthContexts();
|
||||
|
||||
const state = normalizePendingString(req.body?.state);
|
||||
if (!state) {
|
||||
return res.json({ success: true, context: null });
|
||||
}
|
||||
|
||||
const name = normalizePendingString(req.body?.name);
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'MCP server name is required' });
|
||||
}
|
||||
|
||||
const entry = {
|
||||
name,
|
||||
directory: normalizePendingString(req.body?.directory),
|
||||
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
|
||||
};
|
||||
pendingMcpAuthContextByState.set(state, entry);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
context: {
|
||||
name: entry.name,
|
||||
directory: entry.directory,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to store pending MCP auth context:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mcp/auth/pending', async (req, res) => {
|
||||
try {
|
||||
pruneExpiredPendingMcpAuthContexts();
|
||||
|
||||
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
|
||||
if (!state) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
|
||||
if (!pendingMcpAuthContext) {
|
||||
return res.status(404).json({ error: 'No pending MCP auth context' });
|
||||
}
|
||||
|
||||
return res.json(pendingMcpAuthContext);
|
||||
} catch (error) {
|
||||
console.error('Failed to read pending MCP auth context:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mcp/auth/pending', async (req, res) => {
|
||||
try {
|
||||
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
|
||||
if (!state) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
pendingMcpAuthContextByState.delete(state);
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear pending MCP auth context:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user