diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 86bbf564..fc32819e 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -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", diff --git a/bun.lock b/bun.lock index fcfe94cf..a1518f63 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index 1ebbe20b..4bfcb293 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/package.json b/packages/ui/package.json index deb1381b..4f167a7d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 35649756..a1eb4fe2 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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(() => 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 ( + + + + ); + } + // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { // Check if this is the Agent Manager panel diff --git a/packages/ui/src/components/mcp/McpDropdown.tsx b/packages/ui/src/components/mcp/McpDropdown.tsx index 5e892f89..eaf9ff63 100644 --- a/packages/ui/src/components/mcp/McpDropdown.tsx +++ b/packages/ui/src/components/mcp/McpDropdown.tsx @@ -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 = ({ 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(null); @@ -73,14 +76,27 @@ export const McpDropdownContent: React.FC = ({ 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(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 = ({ 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 = ({ 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(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(); diff --git a/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx new file mode 100644 index 00000000..d6565726 --- /dev/null +++ b/packages/ui/src/components/sections/mcp/McpOAuthCallbackPage.tsx @@ -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 ( +
+
+
+
+

+ {status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'} +

+
+

{message}

+
+ + {status !== 'working' && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 96a97802..df21979e 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -4,26 +4,39 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { openExternalUrl } from '@/lib/url'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { useMcpConfigStore, envRecordToArray, type McpDraft, type McpScope, } from '@/stores/useMcpConfigStore'; +import { + parseImportedMcpSnippet, + applyImportedMcpToDraft, +} from './mcpImport'; import { useMcpStore } from '@/stores/useMcpStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { RiAddLine, + RiArrowDownSLine, + RiArrowRightSLine, RiClipboardLine, RiDeleteBinLine, RiEyeLine, RiEyeOffLine, + RiExternalLinkLine, + RiFileCodeLine, RiFolderLine, RiPlugLine, RiUser3Line, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Dialog, DialogContent, @@ -71,6 +84,37 @@ function parseShellCommand(raw: string): string[] { return args; } +function extractAuthorizationResponse(raw: string): { + code: string | null; + context: { name: string; directory: string | null } | null; + stateKey: string | null; +} { + const trimmed = raw.trim(); + if (!trimmed) { + return { code: null, context: null, stateKey: null }; + } + + try { + const parsed = new URL(trimmed); + const code = parsed.searchParams.get('code'); + if (typeof code === 'string' && code.trim()) { + return { + code: code.trim(), + context: parseMcpOAuthCallbackContext(parsed.searchParams), + stateKey: parseMcpOAuthCallbackStateKey(parsed.searchParams), + }; + } + } catch { + // Fall through to treating the pasted value as a raw authorization code. + } + + return { + code: trimmed, + context: null, + stateKey: null, + }; +} + const CommandTextarea: React.FC = ({ value, onChange }) => { // Internal: one arg per line const [text, setText] = React.useState(() => value.join('\n')); @@ -106,7 +150,7 @@ const CommandTextarea: React.FC = ({ value, onChange }) => }; return ( -
+
@@ -174,9 +218,24 @@ interface EnvEntry { key: string; value: string; } interface EnvEditorProps { value: EnvEntry[]; onChange: (v: EnvEntry[]) => void; + keyTransform?: (value: string) => string; + keyPlaceholder?: string; + keyInputClassName?: string; + pasteLabel?: string; + pasteTitle?: string; } -const EnvEditor: React.FC = ({ value, onChange }) => { +const normalizeEnvKey = (value: string): string => value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'); + +const EnvEditor: React.FC = ({ + value, + onChange, + keyTransform = normalizeEnvKey, + keyPlaceholder = 'API_KEY', + keyInputClassName = 'w-36 shrink-0 font-mono typography-meta uppercase', + pasteLabel = 'Paste .env', + pasteTitle = 'Paste KEY=VALUE lines from clipboard', +}) => { const [revealedKeys, setRevealedKeys] = React.useState>(new Set()); const addRow = () => onChange([...value, { key: '', value: '' }]); @@ -256,10 +315,10 @@ const EnvEditor: React.FC = ({ value, onChange }) => { className="!font-normal gap-1 text-muted-foreground" onClick={handlePasteDotEnv} type="button" - title="Paste KEY=VALUE lines from clipboard" + title={pasteTitle} > - Paste .env + {pasteLabel}
@@ -270,9 +329,12 @@ const EnvEditor: React.FC = ({ value, onChange }) => { {/* KEY — fixed narrow width */} updateRow(idx, 'key', e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'))} - placeholder="API_KEY" - className="w-36 shrink-0 font-mono typography-meta uppercase" + onChange={(e) => updateRow(idx, 'key', keyTransform(e.target.value))} + placeholder={keyPlaceholder} + className={keyInputClassName} + data-bwignore="true" + data-1p-ignore="true" + data-lpignore="true" spellCheck={false} /> {/* VALUE — takes remaining space */} @@ -283,6 +345,10 @@ const EnvEditor: React.FC = ({ value, onChange }) => { onChange={(e) => updateRow(idx, 'value', e.target.value)} placeholder="value" className="font-mono typography-meta pr-8 w-full" + autoComplete="new-password" + data-bwignore="true" + data-1p-ignore="true" + data-lpignore="true" spellCheck={false} /> + <> + + {mcpType === 'remote' && ( + <> + + + + )} + {isConnected && ( + + )} + )}
+ {/* Runtime Status - Simplified for connected, expanded for errors */} + {!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && ( +
+
+
+
+
+ Runtime Status + +
+

{runtimeDescription}

+

+ {draftScope === 'project' + ? `Project-scoped to ${currentDirectory ?? 'the active project'}` + : 'User-scoped configuration'} +

+
+ +
+ {!isConnected && ( + + )} +
+ + {authUrl && ( +
+
+
Authorization URL
+
{authUrl}
+
+ + +
+
+
+ )} + + {mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && ( +
+
+
+
Manual Authorization Fallback
+

+ If the browser returns to a different machine or shows a callback URL with a code, paste that full URL or the raw code here. +

+
+