diff --git a/CHANGELOG.md b/CHANGELOG.md index 365d74d6..65269f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Chat: Added support for model variants (thinking effort). +- Chat: added support for model variants (thinking effort). - Shortcuts: Switched agent cycling shortcut from TAB to Shift + TAB. - Skills: added autocomplete for skills on "/" when it is not the first character in input. - Autocomplete: added scope badges for commands/agents/skills. - Compact: changed /summarize command to be /compact and use sdk for compaction. +- MCP: added ability to dynamically enabled/disabled configured MCP. ## [1.4.4] - 2026-01-08 diff --git a/bun.lock b/bun.lock index a851b9d0..303037d1 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -105,7 +105,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -114,6 +114,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", @@ -171,7 +172,7 @@ "version": "1.4.4", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -197,7 +198,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -532,7 +533,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.6", "", {}, "sha512-7Tiso9BExVgxz86VY6F807McCyOgu/SCaQJ87wwxxVSN8GpPpmUIYN5h6LH38EBNJWKXDjokasn/y9EkKxOisQ=="], "@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="], @@ -586,6 +587,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], diff --git a/package.json b/package.json index 2c5d1967..013e28b3 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index 062f5895..4b682b91 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,7 +17,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@pierre/diffs": "^1.0.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -26,6 +26,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", diff --git a/packages/ui/src/components/icons/McpIcon.tsx b/packages/ui/src/components/icons/McpIcon.tsx new file mode 100644 index 00000000..b1484cbe --- /dev/null +++ b/packages/ui/src/components/icons/McpIcon.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react'; + +export function McpIcon(props: SVGProps) { + return ( + + + + + ); +} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index ae30dc0e..c8c54fd6 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -14,6 +14,7 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; +import { McpDropdown } from '@/components/mcp/McpDropdown'; interface TabConfig { id: MainTab; @@ -397,22 +398,25 @@ export const Header: React.FC = () => {
{} -
- - - - - -

Command Palette ({getModifierLabel()}+K)

-
-
+
+ + + + + +

Command Palette ({getModifierLabel()}+K)

+
+
+ + +
-
-
+
+ +
{tabs.map((tab) => { const isActive = activeMainTab === tab.id; const Icon = tab.icon; @@ -501,6 +506,8 @@ export const Header: React.FC = () => { })}
+ + )} + {showMcp && ( + + )} {onSettings && ( + ); + + // Mobile: use MobileOverlayPanel + if (isMobile) { + return ( + <> + {triggerButton} + setOpen(false)} + renderHeader={(closeButton) => ( +
+

MCP Servers

+
+ + {closeButton} +
+
+ )} + > +
+ {renderServerList()} +
+ {directory && ( +
+ + {directory.split('/').pop() || directory} + +
+ )} +
+ + ); + } + + // Desktop: use DropdownMenu + return ( + + + + + {triggerButton} + + + +

MCP Servers

+
+
+ + +
+ MCP Servers + +
+ + + +
+ {renderServerList()} +
+ + {directory && ( + <> + +
+ + {directory.split('/').pop() || directory} + +
+ + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx new file mode 100644 index 00000000..f7b9bff0 --- /dev/null +++ b/packages/ui/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +const Switch = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 3f0cc9e4..ea4797bb 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -9,6 +9,7 @@ import type { Part, Session, Message } from '@opencode-ai/sdk/v2'; import type { PermissionRequest } from '@/types/permission'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; +import { useMcpStore } from '@/stores/useMcpStore'; interface EventData { type: string; @@ -593,6 +594,12 @@ export const useEventStream = () => { break; } + case 'mcp.tools.changed': { + const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory; + void useMcpStore.getState().refresh({ directory: directory ?? null, silent: true }); + break; + } + case 'session.status': if (isDesktopRuntimeRef.current) break; { @@ -1154,7 +1161,8 @@ export const useEventStream = () => { updateSessionActivityPhase, updateSession, removeSessionFromStore, - bootstrapState + bootstrapState, + effectiveDirectory ]); const shouldHoldConnection = React.useCallback(() => { diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts new file mode 100644 index 00000000..4fe9b7f5 --- /dev/null +++ b/packages/ui/src/stores/useMcpStore.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { McpStatus } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; + +export type McpStatusMap = Record; + +const EMPTY_STATUS: McpStatusMap = {}; + +type McpHealth = { + connected: number; + total: number; + hasFailed: boolean; + hasAuthRequired: boolean; +}; + +const normalizeDirectory = (directory: string | null | undefined): string | null => { + if (typeof directory !== 'string') return null; + const trimmed = directory.trim(); + if (!trimmed) return null; + const normalized = trimmed.replace(/\\/g, '/'); + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; +}; + +const toKey = (directory: string | null | undefined): string => normalizeDirectory(directory) ?? '__global__'; + +export const computeMcpHealth = (status: McpStatusMap | null | undefined): McpHealth => { + const entries = Object.entries(status ?? {}); + const connected = entries.filter(([, s]) => s?.status === 'connected').length; + const total = entries.length; + const hasFailed = entries.some(([, s]) => s?.status === 'failed'); + const hasAuthRequired = entries.some(([, s]) => s?.status === 'needs_auth' || s?.status === 'needs_client_registration'); + return { connected, total, hasFailed, hasAuthRequired }; +}; + +type RefreshOptions = { + directory?: string | null; + silent?: boolean; +}; + +interface McpStore { + byDirectory: Record; + loadingKeys: Record; + lastErrorKeys: Record; + + getStatusForDirectory: (directory?: string | null) => McpStatusMap; + refresh: (options?: RefreshOptions) => Promise; + connect: (name: string, directory?: string | null) => Promise; + disconnect: (name: string, directory?: string | null) => Promise; +} + +export const useMcpStore = create()( + devtools((set, get) => ({ + byDirectory: {}, + loadingKeys: {}, + lastErrorKeys: {}, + + getStatusForDirectory: (directory) => { + const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory); + return get().byDirectory[key] ?? EMPTY_STATUS; + }, + + refresh: async (options) => { + const directory = normalizeDirectory(options?.directory ?? useDirectoryStore.getState().currentDirectory); + const key = toKey(directory); + + if (!options?.silent) { + set((state) => ({ + loadingKeys: { ...state.loadingKeys, [key]: true }, + lastErrorKeys: { ...state.lastErrorKeys, [key]: null }, + })); + } + + try { + const api = opencodeClient.getApiClient(); + const result = await api.mcp.status(directory ? { directory } : undefined); + const data = (result.data ?? {}) as McpStatusMap; + + set((state) => ({ + byDirectory: { ...state.byDirectory, [key]: data }, + loadingKeys: { ...state.loadingKeys, [key]: false }, + lastErrorKeys: { ...state.lastErrorKeys, [key]: null }, + })); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load MCP status'; + set((state) => ({ + loadingKeys: { ...state.loadingKeys, [key]: false }, + lastErrorKeys: { ...state.lastErrorKeys, [key]: message }, + })); + } + }, + + connect: async (name, directory) => { + const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory); + const api = opencodeClient.getApiClient(); + await api.mcp.connect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true }); + await get().refresh({ directory: normalized, silent: true }); + }, + + disconnect: async (name, directory) => { + const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory); + const api = opencodeClient.getApiClient(); + await api.mcp.disconnect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true }); + await get().refresh({ directory: normalized, silent: true }); + }, + + })) +); + diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 93d03c93..c2110814 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -197,7 +197,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index dd2f3afa..4efadac3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", - "@opencode-ai/sdk": "^1.1.1", + "@opencode-ai/sdk": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",